Src/Private/Connect-SharePointSession.ps1

function Connect-SharePointSession {
    <#
    .SYNOPSIS
    Establishes authenticated connections to Microsoft Graph and SharePoint Online.
    .NOTES
        Version: 0.1.7
        Author: Pai Wei Sing
    #>

    [CmdletBinding()]
    param (
        [Parameter(Mandatory)]
        [string]$UserPrincipalName,

        [Parameter(Mandatory)]
        [string]$TenantAdminUrl
    )

    if (-not (Test-UserPrincipalName -UserPrincipalName $UserPrincipalName)) {
        throw "Invalid User Principal Name format: '$UserPrincipalName'. Expected format: user@domain.com"
    }

    Write-TranscriptLog "Starting connection for SharePoint report: $UserPrincipalName" 'INFO' 'AUTH'

    $script:CachedOrg    = $null
    $script:CachedSkus   = $null
    $script:CachedLabels = @()

    #region ── Microsoft Graph ──────────────────────────────────────────────────
    $ExistingGraph = $null
    try { $ExistingGraph = Get-MgContext -ErrorAction SilentlyContinue } catch { }

    if ($ExistingGraph -and $ExistingGraph.TenantId) {
        Write-TranscriptLog "Reusing existing Microsoft Graph session (TenantId: $($ExistingGraph.TenantId))" 'SUCCESS' 'AUTH'
    } else {
        Write-Host " - Connecting to Microsoft Graph..."
        Write-TranscriptLog "Connecting to Microsoft Graph" 'INFO' 'AUTH'

        # Only the minimum scope required for core functionality.
        # AuditLog.Read.All and InformationProtectionPolicy.Read.All are optional --
        # they require additional consent and cause auth failures on some tenants.
        # Those sections gracefully degrade when data is unavailable.
        $GraphScopes = @(
            'Organization.Read.All'
        )

        # Resolve the tenant GUID from the tenant domain using the public
        # OpenID configuration endpoint (no authentication required).
        # Passing -TenantId to Connect-MgGraph lets MSAL look up a cached
        # token for this specific tenant first, enabling silent re-auth
        # without a WAM popup when a valid token already exists in the cache.
        $TenantGuid = $null
        try {
            $TenantPrefix = ($TenantAdminUrl -replace 'https://', '' -replace '-admin\.sharepoint\.com.*', '')
            $OidcUrl      = "https://login.microsoftonline.com/$TenantPrefix.onmicrosoft.com/v2.0/.well-known/openid-configuration"
            $OidcResp     = Invoke-RestMethod -Uri $OidcUrl -Method GET -ErrorAction Stop
            # issuer looks like https://login.microsoftonline.com/{guid}/v2.0
            $TenantGuid   = ($OidcResp.issuer -split '/') | Where-Object { $_ -match '^[0-9a-f-]{36}$' } | Select-Object -First 1
            if ($TenantGuid) {
                Write-Host " - Tenant GUID resolved: $TenantGuid" -ForegroundColor DarkGray
            }
        } catch {
            Write-TranscriptLog "Could not resolve tenant GUID (non-fatal): $($_.Exception.Message)" 'WARNING' 'AUTH'
        }

        $ConnectParams = @{ Scopes = $GraphScopes; ErrorAction = 'Stop' }
        if ($TenantGuid) { $ConnectParams['TenantId'] = $TenantGuid }

        # Try interactive browser first.
        # If WAM fails (known issue with Graph SDK v2.x on some Windows builds),
        # fall back to device code. Device code output goes directly to the console
        # host via [Console]::Write which PScribo does NOT capture -- unlike
        # Write-Host/Write-Information which PScribo intercepts.
        $GraphConnected = $false
        try {
            Connect-MgGraph @ConnectParams -ErrorAction Stop
            $GraphConnected = $true
        } catch {
            if ($_.Exception.Message -like '*InteractiveBrowserCredential*' -or
                $_.Exception.Message -like '*authentication failed*') {
                Write-Host " - Interactive browser auth failed. Falling back to device code..." -ForegroundColor Yellow
                Write-Host " - Open https://microsoft.com/devicelogin and enter the code below." -ForegroundColor Yellow
                $ConnectParams['UseDeviceCode'] = $true
                Connect-MgGraph @ConnectParams -ErrorAction Stop
                $GraphConnected = $true
            } else {
                throw
            }
        }

        $MgCtx = Get-MgContext -ErrorAction SilentlyContinue
        if ($MgCtx -and $MgCtx.TenantId) {
            Write-Host " - Microsoft Graph connected successfully." -ForegroundColor Green
            Write-TranscriptLog "Microsoft Graph connection verified (TenantId: $($MgCtx.TenantId))" 'SUCCESS' 'AUTH'
        } else {
            throw "Connect-MgGraph succeeded but Get-MgContext returned no context."
        }
    }
    #endregion

    #region ── Pre-fetch Graph data BEFORE PnP connects ─────────────────────────
    # PnP.PowerShell v3 loads Microsoft.Graph.Core v1.25.1 which conflicts with
    # Graph SDK v2.x (needs Core v3.x). After PnP connects ALL Graph SDK calls fail.
    # Fetch everything needed now while Graph SDK still works.
    Write-Host " - Pre-fetching Graph data before PnP connects..." -ForegroundColor Cyan

    try {
        $OrgResp = Invoke-MgGraphRequest -Method GET `
            -Uri 'https://graph.microsoft.com/v1.0/organization?$select=id,displayName,verifiedDomains,countryLetterCode,preferredLanguage,createdDateTime' `
            -ErrorAction Stop
        $script:CachedOrg = if ($OrgResp.value) { $OrgResp.value[0] } else { $OrgResp }
        Write-Host " [OK] Organisation: $($script:CachedOrg.displayName)" -ForegroundColor DarkGray
    } catch {
        Write-TranscriptLog "Pre-fetch org failed: $($_.Exception.Message)" 'WARNING' 'AUTH'
    }

    try {
        $SkuResp = Invoke-MgGraphRequest -Method GET `
            -Uri 'https://graph.microsoft.com/v1.0/subscribedSkus?$select=skuPartNumber,capabilityStatus,prepaidUnits,consumedUnits' `
            -ErrorAction Stop
        $script:CachedSkus = if ($SkuResp.value) { $SkuResp.value } else { @() }
        Write-Host " [OK] Licences: $(@($script:CachedSkus).Count) SKUs" -ForegroundColor DarkGray
    } catch {
        Write-TranscriptLog "Pre-fetch SKUs failed: $($_.Exception.Message)" 'WARNING' 'AUTH'
    }

    try {
        $LabelResp = Invoke-MgGraphRequest -Method GET `
            -Uri 'https://graph.microsoft.com/v1.0/security/informationProtection/sensitivityLabels' `
            -ErrorAction SilentlyContinue
        $script:CachedLabels = if ($LabelResp.value) { $LabelResp.value } else { @() }
        Write-Host " [OK] Sensitivity labels: $(@($script:CachedLabels).Count)" -ForegroundColor DarkGray
    } catch {
        $script:CachedLabels = @()
        Write-TranscriptLog "Pre-fetch labels failed: $($_.Exception.Message)" 'WARNING' 'AUTH'
    }
    #endregion

    #region ── PnP.PowerShell ────────────────────────────────────────────────────
    $script:PnPAvailable = $false

    if (-not (Get-Module -ListAvailable -Name PnP.PowerShell -ErrorAction SilentlyContinue)) {
        Write-Warning " [!] PnP.PowerShell not installed. Run: Install-Module -Name PnP.PowerShell -Force"
        return
    }

    $ExistingPnP = $null
    try { $ExistingPnP = Get-PnPConnection -ErrorAction SilentlyContinue } catch { }

    if ($ExistingPnP) {
        Write-TranscriptLog "Reusing existing PnP.PowerShell session" 'SUCCESS' 'AUTH'
        $script:PnPAvailable = $true
        return
    }

    $ConfigClientId = $null
    try { $ConfigClientId = $script:Options.PnP.ClientId } catch { }
    $HasClientId = ($ConfigClientId -and "$ConfigClientId".Trim() -ne '')

    if (-not $HasClientId) {
        Write-Warning " [!] Options.PnP.ClientId is not set in the report config JSON."
        Write-Warning " SharePoint tenant settings, site collections, and OneDrive data will be unavailable."
        Write-PnPConnectionGuidance -TenantAdminUrl $TenantAdminUrl -UserPrincipalName $UserPrincipalName
        return
    }

    $PnPVersion = (Get-Module -ListAvailable -Name PnP.PowerShell |
        Sort-Object Version -Descending | Select-Object -First 1).Version
    Write-Host " - Connecting PnP.PowerShell v$PnPVersion to $TenantAdminUrl..." -ForegroundColor Cyan

    try {
        Invoke-WithRetry -ScriptBlock {
            Connect-PnPOnline -Url $TenantAdminUrl -ClientId $ConfigClientId `
                -Interactive -ErrorAction Stop
        } -OperationName 'Connect to SharePoint Online (PnP)'

        $script:PnPAvailable = $true
        Write-Host " - PnP.PowerShell connected." -ForegroundColor Green
        Write-TranscriptLog "PnP connected (ClientId: $ConfigClientId)" 'SUCCESS' 'AUTH'
    } catch {
        Write-Warning " [!] PnP connection failed: $($_.Exception.Message)"
        Write-Warning " Continuing in Graph-only mode."
        Write-PnPConnectionGuidance -TenantAdminUrl $TenantAdminUrl -UserPrincipalName $UserPrincipalName
        $script:PnPAvailable = $false
    }
    #endregion

    Write-Host " - Connection setup complete." -ForegroundColor Green
    Write-TranscriptLog "Connection complete for: $UserPrincipalName" 'SUCCESS' 'AUTH'
}


function Write-PnPConnectionGuidance {
    [CmdletBinding()]
    param(
        [string]$TenantAdminUrl = '',
        [string]$UserPrincipalName = ''
    )

    $TenantPrefix = ''
    try { $TenantPrefix = ($TenantAdminUrl -replace 'https://', '' -replace '-admin\.sharepoint\.com.*', '') } catch { }
    $TenantFqdn   = if ($TenantPrefix) { "$TenantPrefix.onmicrosoft.com" } else { 'contoso.onmicrosoft.com' }
    $AppName      = "AsBuiltReport.SharePoint$(if ($TenantPrefix) { ".$TenantPrefix" } else { '' })"

    Write-Host ""
    Write-Host " ┌──────────────────────────────────────────────────────────────────────┐" -ForegroundColor Yellow
    Write-Host " │ HOW TO SET UP: PnP App Registration │" -ForegroundColor Yellow
    Write-Host " └──────────────────────────────────────────────────────────────────────┘" -ForegroundColor Yellow
    Write-Host ""
    Write-Host " Run once to register an app and get a Client ID:" -ForegroundColor White
    Write-Host ""
    Write-Host " `$app = Register-PnPEntraIDAppForInteractiveLogin \`" -ForegroundColor Green
    Write-Host "
        -ApplicationName '$AppName' \`" -ForegroundColor Green
    Write-Host " -Tenant '$TenantFqdn' \`" -ForegroundColor Green
    Write-Host "
        -SharePointDelegatePermissions 'AllSites.Read','TermStore.Read.All','User.ReadBasic.All'" -ForegroundColor Green
    Write-Host "
    `$app.AzureAppId   # copy this into JSON config Options.PnP.ClientId" -ForegroundColor Green
    Write-Host ""
    Write-Host " Then add to AsBuiltReport.Microsoft.SharePoint.json:" -ForegroundColor White
    Write-Host ' "PnP": { "ClientId": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" }' -ForegroundColor Green
    Write-Host ""
}


function Disconnect-SharePointSession {
    [CmdletBinding()]
    param()

    Write-TranscriptLog "Disconnecting SharePoint sessions" 'INFO' 'AUTH'

    try {
        $GraphCtx = Get-MgContext -ErrorAction SilentlyContinue
        if ($GraphCtx) {
            Write-Host " - Disconnecting Microsoft Graph..."
            $null = Disconnect-MgGraph -ErrorAction SilentlyContinue
            Write-TranscriptLog "Microsoft Graph disconnected" 'SUCCESS' 'AUTH'
        }
    } catch {
        Write-TranscriptLog "Graph disconnect warning: $($_.Exception.Message)" 'WARNING' 'AUTH'
    }

    if ($script:PnPAvailable) {
        try {
            Write-Host " - Disconnecting PnP.PowerShell..."
            $null = Disconnect-PnPOnline -ErrorAction SilentlyContinue
            Write-TranscriptLog "PnP disconnected" 'SUCCESS' 'AUTH'
        } catch {
            Write-TranscriptLog "PnP disconnect warning: $($_.Exception.Message)" 'WARNING' 'AUTH'
        }
    }

    Write-Host " - Sessions disconnected." -ForegroundColor Green
}