Common/Connect-Service.ps1

<#
.SYNOPSIS
    Connects to Microsoft cloud services with standardized error handling.
.DESCRIPTION
    Wraps Connect-MgGraph, Connect-ExchangeOnline, and Connect-IPPSSession
    with consistent error handling, required module checks, and scope management.
    Supports interactive, certificate, client secret, and managed identity authentication.
.PARAMETER Service
    The service to connect to: Graph, ExchangeOnline, or Purview.
.PARAMETER Scopes
    Microsoft Graph permission scopes. Only used with the Graph service.
    Defaults to 'User.Read.All' if not specified.
.PARAMETER TenantId
    The tenant ID or domain (e.g., 'contoso.onmicrosoft.com'). Optional for
    interactive auth but required for app-only auth.
.PARAMETER ClientId
    Application (client) ID for app-only authentication. Requires TenantId
    and either CertificateThumbprint or ClientSecret.
.PARAMETER CertificateThumbprint
    Certificate thumbprint for app-only authentication. For Exchange Online and Purview
    this is Windows-only (Connect-ExchangeOnline / Connect-IPPSSession resolve it through
    the Windows certificate store); on Linux/macOS pass -Certificate or -CertificatePath.
.PARAMETER Certificate
    App-only authentication certificate as an X509Certificate2 object. Portable across
    Windows, Linux and macOS -- the recommended input for non-Windows Exchange/Purview.
.PARAMETER CertificatePath
    Path to a certificate file (.pfx/.p12) for app-only authentication; loaded with
    -CertificatePassword. Portable alternative to -CertificateThumbprint.
.PARAMETER CertificatePassword
    SecureString password protecting the -CertificatePath file, if any.
.PARAMETER ClientSecret
    Client secret for app-only authentication. Less secure than certificate auth.
.PARAMETER UserPrincipalName
    User principal name (e.g., 'admin@contoso.onmicrosoft.com') for interactive
    authentication to Exchange Online or Purview. Bypasses the Windows Authentication
    Manager (WAM) broker which can cause RuntimeBroker errors on some systems.
.PARAMETER ManagedIdentity
    Use Azure managed identity authentication. Requires the script to be running
    on an Azure resource with a system-assigned or user-assigned managed identity
    (e.g., Azure VM, Azure Functions, Azure Automation). Graph uses -Identity,
    Exchange Online uses -ManagedIdentity. Purview and Power BI do not support
    managed identity and will fall back with a warning.
.PARAMETER UseDeviceCode
    Use device code authentication flow instead of browser-based interactive auth.
    Graph uses -UseDeviceCode, Exchange Online uses -Device. Purview does not
    support device code and will fall back to browser/UPN-based auth with a warning.
.PARAMETER M365Environment
    Target cloud environment. Commercial and GCC use standard endpoints.
    GCCHigh and DoD route to sovereign cloud endpoints for Graph, Exchange,
    and Purview. Defaults to 'commercial'.
.EXAMPLE
    PS> .\Common\Connect-Service.ps1 -Service Graph -Scopes 'User.Read.All','Group.Read.All'

    Connects to Microsoft Graph interactively with the specified scopes.
.EXAMPLE
    PS> .\Common\Connect-Service.ps1 -Service ExchangeOnline -TenantId 'contoso.onmicrosoft.com'

    Connects to Exchange Online for the specified tenant.
.EXAMPLE
    PS> .\Common\Connect-Service.ps1 -Service Graph -TenantId 'contoso.onmicrosoft.com' -ClientId '00000000-0000-0000-0000-000000000000' -CertificateThumbprint 'ABC123'

    Connects to Microsoft Graph using certificate-based app-only auth.
.EXAMPLE
    PS> .\Common\Connect-Service.ps1 -Service Purview -UserPrincipalName 'admin@contoso.onmicrosoft.com'

    Connects to Purview using the specified UPN (avoids WAM broker issues).
.EXAMPLE
    PS> .\Common\Connect-Service.ps1 -Service Graph -M365Environment gcchigh -TenantId 'contoso.onmicrosoft.us'

    Connects to Microsoft Graph in the GCC High sovereign cloud.
#>

[CmdletBinding()]
param(
    [Parameter(Mandatory)]
    [ValidateSet('Graph', 'ExchangeOnline', 'Purview', 'PowerBI')]
    [string]$Service,

    [Parameter()]
    [string[]]$Scopes = @('User.Read.All'),

    [Parameter()]
    [string]$TenantId,

    [Parameter()]
    [string]$ClientId,

    [Parameter()]
    [string]$CertificateThumbprint,

    [Parameter()]
    [System.Security.Cryptography.X509Certificates.X509Certificate2]$Certificate,

    [Parameter()]
    [string]$CertificatePath,

    [Parameter()]
    [SecureString]$CertificatePassword,

    [Parameter()]
    [SecureString]$ClientSecret,

    [Parameter()]
    [string]$UserPrincipalName,

    [Parameter()]
    [switch]$ManagedIdentity,

    [Parameter()]
    [switch]$UseDeviceCode,

    [Parameter()]
    [ValidateSet('commercial', 'gcc', 'gcchigh', 'dod')]
    [string]$M365Environment = 'commercial'
)

$ErrorActionPreference = 'Stop'

function Resolve-AppOnlyCertificate {
    <#
    .SYNOPSIS
        Returns the app-only authentication certificate as an X509Certificate2, from either the
        -Certificate object or the -CertificatePath (+ -CertificatePassword) file, or $null when
        neither is supplied. Portable across Windows, Linux and macOS -- it never touches the
        Windows-only PowerShell Cert: provider.
    #>

    [OutputType([System.Security.Cryptography.X509Certificates.X509Certificate2])]
    param(
        [System.Security.Cryptography.X509Certificates.X509Certificate2]$Certificate,
        [string]$CertificatePath,
        [System.Security.SecureString]$CertificatePassword
    )
    if ($Certificate) { return $Certificate }
    if (-not $CertificatePath) { return $null }
    if (-not (Test-Path -Path $CertificatePath)) {
        throw "Certificate file not found: '$CertificatePath'."
    }
    try {
        if ($CertificatePassword) {
            return [System.Security.Cryptography.X509Certificates.X509Certificate2]::new($CertificatePath, $CertificatePassword)
        }
        return [System.Security.Cryptography.X509Certificates.X509Certificate2]::new($CertificatePath)
    }
    catch {
        throw "Failed to load certificate from '$CertificatePath': $($_.Exception.Message)"
    }
}

function Resolve-InitialDomain {
    <#
    .SYNOPSIS
        Resolves the tenant's initial (*.onmicrosoft.*) domain, which app-only Exchange Online /
        Purview authentication requires as -Organization. Prefers -TenantId when it already is an
        initial domain; otherwise queries Microsoft Graph with a RELATIVE request so the connected
        sovereign-cloud endpoint (commercial, GCC High, DoD) is honoured. Returns $null on failure.
    #>

    [OutputType([string])]
    param([string]$TenantId)
    if ($TenantId -and $TenantId -match '\.onmicrosoft\.[a-z]+$') { return $TenantId }
    try {
        $verifiedDomains = (Invoke-MgGraphRequest -Method GET -Uri '/v1.0/organization?$select=verifiedDomains').value.verifiedDomains
        $initial = $verifiedDomains | Where-Object { $_.isInitial } | Select-Object -First 1
        if ($initial.name) { return [string]$initial.name }
    }
    catch {
        Write-Verbose "Initial-domain lookup via Graph failed: $($_.Exception.Message)"
    }
    return $null
}

function Set-ExchangeAppOnlyAuth {
    <#
    .SYNOPSIS
        Configures Exchange Online / Purview app-only certificate authentication cross-platform.
    .DESCRIPTION
        Connect-ExchangeOnline and Connect-IPPSSession resolve -CertificateThumbprint only through
        the Windows certificate store, which is unavailable on Linux/macOS. When a certificate
        object is supplied it is passed via -Certificate together with -Organization (the tenant's
        initial domain). Windows keeps the unchanged -CertificateThumbprint code path. Fails early
        with an actionable error when the required material cannot be resolved.
    #>

    param(
        [Parameter(Mandatory)][hashtable]$ConnectParams,
        [Parameter(Mandatory)][string]$ClientId,
        [string]$CertificateThumbprint,
        [System.Security.Cryptography.X509Certificates.X509Certificate2]$Certificate,
        [string]$TenantId
    )
    $ConnectParams['AppId'] = $ClientId
    if ($Certificate) {
        $ConnectParams['Certificate'] = $Certificate
        $organization = Resolve-InitialDomain -TenantId $TenantId
        if (-not $organization) {
            throw "Could not resolve the tenant's initial (*.onmicrosoft.*) domain, which app-only Exchange Online / Purview authentication requires. Connect to Microsoft Graph first, or pass -TenantId as the initial domain."
        }
        $ConnectParams['Organization'] = $organization
    }
    elseif ($CertificateThumbprint) {
        if ($IsWindows -eq $false) {
            throw "-CertificateThumbprint is Windows-only for Exchange Online / Purview (it is resolved through the Windows certificate store). On Linux/macOS, pass the certificate with -Certificate or -CertificatePath instead."
        }
        $ConnectParams['CertificateThumbprint'] = $CertificateThumbprint
    }
    else {
        throw "App-only Exchange Online / Purview authentication requires a certificate: pass -Certificate, -CertificatePath, or (on Windows) -CertificateThumbprint."
    }
}

# Resolve the app-only certificate object once (from -Certificate or -CertificatePath).
$appOnlyCertificate = Resolve-AppOnlyCertificate -Certificate $Certificate -CertificatePath $CertificatePath -CertificatePassword $CertificatePassword

$moduleMap = @{
    'Graph'           = 'Microsoft.Graph.Authentication'
    'ExchangeOnline'  = 'ExchangeOnlineManagement'
    'Purview'         = 'ExchangeOnlineManagement'
    'PowerBI'         = 'MicrosoftPowerBIMgmt'
}

$requiredModule = $moduleMap[$Service]

# Check that the required module is available
if (-not (Get-Module -Name $requiredModule -ListAvailable)) {
    Write-Error "Required module '$requiredModule' is not installed. Run: Install-Module -Name $requiredModule -Scope CurrentUser"
    return
}

try {
    # ------------------------------------------------------------------
    # Environment endpoint configuration
    # GCC uses the same endpoints as commercial (tenant is in the GCC
    # partition but API surface is identical). GCC High and DoD route
    # to sovereign cloud endpoints.
    # ------------------------------------------------------------------
    $envConfig = @{
        'commercial' = @{ GraphEnvironment = $null; ExoEnvironment = $null; PurviewParams = @{} }
        'gcc'        = @{ GraphEnvironment = $null; ExoEnvironment = $null; PurviewParams = @{} }
        'gcchigh'    = @{
            GraphEnvironment = 'USGov'
            ExoEnvironment   = 'O365USGovGCCHigh'
            PurviewParams    = @{
                ConnectionUri                   = 'https://ps.compliance.protection.office365.us/powershell-liveid/'
                AzureADAuthorizationEndpointUri = 'https://login.microsoftonline.us/common'
            }
        }
        'dod'        = @{
            GraphEnvironment = 'USGovDoD'
            ExoEnvironment   = 'O365USGovDoD'
            PurviewParams    = @{
                ConnectionUri                   = 'https://l5.ps.compliance.protection.office365.us/powershell-liveid/'
                AzureADAuthorizationEndpointUri = 'https://login.microsoftonline.us/common'
            }
        }
    }

    $currentEnv = $envConfig[$M365Environment]

    switch ($Service) {
        'Graph' {
            $connectParams = @{}
            if ($TenantId) { $connectParams['TenantId'] = $TenantId }

            if ($ManagedIdentity) {
                $connectParams['Identity'] = $true
            }
            elseif ($ClientId -and ($appOnlyCertificate -or $CertificateThumbprint)) {
                $connectParams['ClientId'] = $ClientId
                # Connect-MgGraph accepts a certificate object cross-platform; the thumbprint
                # path stays for Windows callers that rely on the certificate store.
                if ($appOnlyCertificate) { $connectParams['Certificate'] = $appOnlyCertificate }
                else { $connectParams['CertificateThumbprint'] = $CertificateThumbprint }
            }
            elseif ($ClientId -and $ClientSecret) {
                $credential = New-Object -TypeName System.Management.Automation.PSCredential -ArgumentList $ClientId, $ClientSecret
                $connectParams['ClientSecretCredential'] = $credential
                Write-Warning 'Graph: client secret authentication is supported but certificate authentication is recommended for unattended assessments. Pass -CertificateThumbprint where possible.'
            }
            else {
                $connectParams['Scopes'] = $Scopes
                if ($UseDeviceCode) {
                    $connectParams['UseDeviceCode'] = $true
                }
            }

            if ($currentEnv.GraphEnvironment) {
                $connectParams['Environment'] = $currentEnv.GraphEnvironment
            }

            # Suppress Graph SDK welcome banner (available in v2.x+)
            if ((Get-Command Connect-MgGraph -ErrorAction SilentlyContinue) -and
                (Get-Command Connect-MgGraph).Parameters.ContainsKey('NoWelcome')) {
                $connectParams['NoWelcome'] = $true
            }

            Connect-MgGraph @connectParams
            Write-Verbose "Connected to Microsoft Graph ($M365Environment)"
        }

        'ExchangeOnline' {
            # #231: EXO 3.8.0+ bundles an MSAL that conflicts with the Graph SDK
            # in-session. Connect-ExchangeOnline auto-loads the HIGHEST installed
            # version, so when a compatible (< 3.8.0) version is installed
            # side-by-side, pin the import to it before connecting.
            if (-not (Get-Module -Name ExchangeOnlineManagement)) {
                $compatibleExo = if (Get-Command -Name Get-CompatibleExoModule -ErrorAction SilentlyContinue) { Get-CompatibleExoModule } else { $null }
                if ($compatibleExo) {
                    Import-Module -Name ExchangeOnlineManagement -RequiredVersion $compatibleExo.Version -ErrorAction Stop
                    Write-Verbose "Pinned ExchangeOnlineManagement $($compatibleExo.Version) for this session"
                }
            }

            $connectParams = @{
                ShowBanner = $false
            }
            if ($TenantId) { $connectParams['Organization'] = $TenantId }

            if ($ManagedIdentity) {
                $connectParams['ManagedIdentity'] = $true
            }
            elseif ($ClientId -and ($appOnlyCertificate -or $CertificateThumbprint)) {
                Set-ExchangeAppOnlyAuth -ConnectParams $connectParams -ClientId $ClientId -CertificateThumbprint $CertificateThumbprint -Certificate $appOnlyCertificate -TenantId $TenantId
            }
            elseif ($ClientId -and $ClientSecret) {
                throw "Exchange Online does not support client secret authentication. Use -CertificateThumbprint for app-only auth."
            }
            elseif ($UseDeviceCode) {
                $connectParams['Device'] = $true
            }
            elseif ($UserPrincipalName) {
                $connectParams['UserPrincipalName'] = $UserPrincipalName
            }

            if ($currentEnv.ExoEnvironment) {
                $connectParams['ExchangeEnvironmentName'] = $currentEnv.ExoEnvironment
            }

            Connect-ExchangeOnline @connectParams
            Write-Verbose "Connected to Exchange Online ($M365Environment)"
        }

        'Purview' {
            # Connect-IPPSSession ships in ExchangeOnlineManagement — same #231
            # side-by-side pin applies (see the ExchangeOnline case above).
            if (-not (Get-Module -Name ExchangeOnlineManagement)) {
                $compatibleExo = if (Get-Command -Name Get-CompatibleExoModule -ErrorAction SilentlyContinue) { Get-CompatibleExoModule } else { $null }
                if ($compatibleExo) {
                    Import-Module -Name ExchangeOnlineManagement -RequiredVersion $compatibleExo.Version -ErrorAction Stop
                    Write-Verbose "Pinned ExchangeOnlineManagement $($compatibleExo.Version) for this session"
                }
            }

            $connectParams = @{}
            if ($TenantId) { $connectParams['Organization'] = $TenantId }

            if ($ManagedIdentity) {
                Write-Warning "Purview (Connect-IPPSSession) does not support managed identity auth. Falling back to browser-based login."
            }

            if ($ClientId -and ($appOnlyCertificate -or $CertificateThumbprint)) {
                Set-ExchangeAppOnlyAuth -ConnectParams $connectParams -ClientId $ClientId -CertificateThumbprint $CertificateThumbprint -Certificate $appOnlyCertificate -TenantId $TenantId
            }
            elseif ($ClientId -and $ClientSecret) {
                throw "Purview does not support client secret authentication. Use -CertificateThumbprint for app-only auth."
            }
            elseif ($UserPrincipalName) {
                $connectParams['UserPrincipalName'] = $UserPrincipalName
            }

            if ($UseDeviceCode) {
                Write-Warning "Purview (Connect-IPPSSession) does not support device code auth. Falling back to browser-based login."
            }

            foreach ($key in $currentEnv.PurviewParams.Keys) {
                $connectParams[$key] = $currentEnv.PurviewParams[$key]
            }

            Connect-IPPSSession @connectParams
            Write-Verbose "Connected to Purview (Security & Compliance) ($M365Environment)"
        }

        'PowerBI' {
            $connectParams = @{}
            if ($TenantId) { $connectParams['Tenant'] = $TenantId }

            # Route sovereign clouds to their Power BI environment. Without this the
            # module defaults to commercial, so the WAM broker uses the commercial
            # redirect URI and GCC High/DoD fail with IncorrectConfiguration /
            # "Invalid redirect uri" (#943). Power BI env names differ from Graph's:
            # gcc->USGov, gcchigh->USGovHigh, dod->USGovMil (api[.high|.mil].powerbigov.us).
            $pbiEnvironmentMap = @{
                'gcc'     = 'USGov'
                'gcchigh' = 'USGovHigh'
                'dod'     = 'USGovMil'
            }
            if ($pbiEnvironmentMap.ContainsKey($M365Environment)) {
                $connectParams['Environment'] = $pbiEnvironmentMap[$M365Environment]
            }

            if ($ManagedIdentity) {
                throw "Power BI (Connect-PowerBIServiceAccount) does not support managed identity auth. Use -ClientId and -CertificateThumbprint for non-interactive auth."
            }
            if ($UseDeviceCode) {
                Write-Warning "Power BI (Connect-PowerBIServiceAccount) does not support device code auth. Falling back to interactive login."
            }
            if ($ClientId -and $CertificateThumbprint) {
                $connectParams['ServicePrincipal'] = $true
                $connectParams['ApplicationId'] = $ClientId
                $connectParams['CertificateThumbprint'] = $CertificateThumbprint
            }
            elseif ($ClientId -and $ClientSecret) {
                $credential = New-Object -TypeName System.Management.Automation.PSCredential -ArgumentList $ClientId, $ClientSecret
                $connectParams['ServicePrincipal'] = $true
                $connectParams['Credential'] = $credential
                Write-Warning 'Power BI: client secret authentication is supported but certificate authentication is recommended for unattended assessments. Pass -CertificateThumbprint where possible.'
            }

            Connect-PowerBIServiceAccount @connectParams -WarningAction SilentlyContinue
            Write-Verbose "Connected to Power BI ($M365Environment)"
        }
    }
}
catch {
    Write-Error "Failed to connect to $Service`: $_"
}