Public/Auth/Connect-SPCTenant.ps1

# PnP 3.x removed Get-PnPGraphAccessToken; provide compat wrapper so existing mocks and call sites work.
if (-not (Get-Command -Name 'Get-PnPGraphAccessToken' -ErrorAction SilentlyContinue)) {
    function Get-PnPGraphAccessToken {
        [CmdletBinding()]
        param([Parameter()] [object] $Connection)
        if ($Connection) { Get-PnPAccessToken -Connection $Connection } else { Get-PnPAccessToken }
    }
}

function Connect-SPCTenant {
    <#
    .SYNOPSIS
        Establishes an authenticated session to a Microsoft 365 tenant for all SPClean cmdlets.
    .DESCRIPTION
        Supports Interactive (device code flow) and AppOnly (certificate or client secret) auth.
        Stores connection state in $script:SPCContext. Also connects Microsoft Graph for user lookups.
    .PARAMETER TenantName
        Tenant hostname: 'contoso', 'contoso.onmicrosoft.com', or 'contoso.sharepoint.com'.
    .PARAMETER AuthMethod
        'Interactive' (default, device code flow) or 'AppOnly' (requires -ClientId + cert or secret).
    .PARAMETER ClientId
        Azure AD app registration client ID. Required for AppOnly.
    .PARAMETER CertificatePath
        Path to .pfx certificate file. Used with AppOnly cert-based auth.
    .PARAMETER CertificatePassword
        SecureString password for the .pfx file. Never plain text.
    .PARAMETER CertificateThumbprint
        Certificate thumbprint in certificate store.
    .PARAMETER ClientSecret
        SecureString client secret. AppOnly alternative to certificate. Mutually exclusive with -CertificatePath.
    .EXAMPLE
        Connect-SPCTenant -TenantName contoso
    .EXAMPLE
        Connect-SPCTenant -TenantName contoso -AuthMethod AppOnly -ClientId '...' -CertificatePath C:\cert.pfx -CertificatePassword $pwd
    .OUTPUTS
        SPC.ConnectionInfo
    #>

    [CmdletBinding()]
    [OutputType([PSCustomObject])]
    param(
        [Parameter(Mandatory = $true)]
        [ValidateNotNullOrEmpty()]
        [string] $TenantName,

        [Parameter()]
        [ValidateSet('Interactive', 'AppOnly')]
        [string] $AuthMethod = 'Interactive',

        [Parameter()]
        [string] $ClientId,

        [Parameter()]
        [string] $CertificatePath,

        [Parameter()]
        [System.Security.SecureString] $CertificatePassword,

        [Parameter()]
        [Alias('Thumbprint')]
        [string] $CertificateThumbprint,

        [Parameter()]
        [System.Security.SecureString] $ClientSecret
    )

    begin {
        # ERR-AUTH-001: derive and validate the admin URL from TenantName
        $shortName = $TenantName `
            -replace '^https?://',              '' `
            -replace '\.onmicrosoft\.com.*$',   '' `
            -replace '(-admin)?\.sharepoint\.com.*$', '' `
            -replace '/$',                      ''

        if ($shortName -notmatch '^[a-zA-Z0-9][a-zA-Z0-9-]*$') {
            throw "ERR-AUTH-001: Cannot resolve tenant URL from TenantName: '$TenantName'. Verify the tenant hostname."
        }
        $adminUrl = "https://${shortName}-admin.sharepoint.com"

        # ERR-AUTH-003: validate AppOnly parameter combination
        if ($AuthMethod -eq 'AppOnly') {
            if ([string]::IsNullOrWhiteSpace($ClientId) -or
                ([string]::IsNullOrWhiteSpace($CertificateThumbprint) -and [string]::IsNullOrWhiteSpace($CertificatePath) -and $null -eq $ClientSecret)) {
                throw 'ERR-AUTH-003: AppOnly auth requires -ClientId and one of: -CertificateThumbprint, -CertificatePath, or -ClientSecret.'
            }
        }

        # ERR-AUTH-004: PnP 3.x removed the implicit default app — ClientId is required for Interactive auth
        if ($AuthMethod -eq 'Interactive' -and [string]::IsNullOrWhiteSpace($ClientId)) {
            throw 'ERR-AUTH-004: Interactive auth requires -ClientId in PnP.PowerShell 3.x. Register an Entra app with delegated permissions (Sites.FullControl.All, User.Read.All, Directory.Read.All, AuditLog.Read.All) and "Allow public client flows" enabled, then pass its client ID here.'
        }
    }

    process {
        $connectedAt = (Get-Date).ToUniversalTime()
        $pnpContext  = $null

        try {
            if ($AuthMethod -eq 'Interactive') {
                Write-Verbose "Connecting interactively to $adminUrl"
                Write-Information "SPClean requires 2 separate logins for Interactive mode. Please complete BOTH browser prompts (1 for SharePoint, 1 for Microsoft Graph)." -InformationAction Continue
                $pnpContext = Connect-PnPOnline -Url $adminUrl -Interactive -ClientId $ClientId -ReturnConnection
                
                # Connect-MgGraph for Graph cmdlets (SRS 3.1.1 step 2)
                Connect-MgGraph -Scopes 'User.Read.All', 'Directory.Read.All', 'AuditLog.Read.All' -NoWelcome -ErrorAction Stop |
                    Out-Null

            } elseif (-not [string]::IsNullOrWhiteSpace($CertificateThumbprint)) {
                Write-Verbose "Connecting AppOnly (certificate thumbprint) to $adminUrl"
                $tenantId   = if ($shortName -match '\.') { $shortName } else { "$shortName.onmicrosoft.com" }
                $pnpContext = Connect-PnPOnline -Url $adminUrl -ClientId $ClientId `
                    -Tenant $tenantId `
                    -Thumbprint $CertificateThumbprint `
                    -ReturnConnection

            } elseif (-not [string]::IsNullOrWhiteSpace($CertificatePath)) {
                Write-Verbose "Connecting AppOnly (certificate file) to $adminUrl"
                # Azure AD requires a valid DNS name; append .onmicrosoft.com when only short name given
                $tenantId   = if ($shortName -match '\.') { $shortName } else { "$shortName.onmicrosoft.com" }
                if ($null -ne $CertificatePassword) {
                    $pnpContext = Connect-PnPOnline -Url $adminUrl -ClientId $ClientId `
                        -Tenant $tenantId `
                        -CertificatePath $CertificatePath -CertificatePassword $CertificatePassword `
                        -ReturnConnection
                } else {
                    $pnpContext = Connect-PnPOnline -Url $adminUrl -ClientId $ClientId `
                        -Tenant $tenantId `
                        -CertificatePath $CertificatePath `
                        -ReturnConnection
                }

            } else {
                Write-Verbose "Connecting AppOnly (client secret) to $adminUrl"
                # SecureString → plain only in-memory, cleared immediately after (SRS 4.3 / E001)
                $bstr = [System.Runtime.InteropServices.Marshal]::SecureStringToBSTR($ClientSecret)
                try {
                    $plain = [System.Runtime.InteropServices.Marshal]::PtrToStringAuto($bstr)
                    $pnpContext = Connect-PnPOnline -Url $adminUrl -ClientId $ClientId `
                        -ClientSecret $plain -ReturnConnection
                } finally {
                    [System.Runtime.InteropServices.Marshal]::ZeroFreeBSTR($bstr)
                    $plain = $null
                }
            }
        } catch {
            # ERR-AUTH-002: propagate underlying auth error with context prefix
            throw "SPClean: Authentication failed. $($_.Exception.Message)"
        }

        $graphToken = Get-PnPGraphAccessToken -Connection $pnpContext

        # Determine Operator UPN for Audit Trail
        $operatorUPN = if ($AuthMethod -eq 'Interactive') {
            # In interactive mode, prefer actual user principal name
            $realUpn = $null
            try {
                $realUpn = (Get-MgContext).Account
            } catch {}
            if (-not $realUpn -and $graphToken) {
                try {
                    $realUpn = (Invoke-RestMethod -Uri "https://graph.microsoft.com/v1.0/me" -Headers @{ Authorization = "Bearer $graphToken" } -ErrorAction SilentlyContinue).userPrincipalName
                } catch {}
            }
            if ($realUpn) {
                $realUpn
            } elseif ($script:SPCContext -and $script:SPCContext.OperatorUPN) {
                $script:SPCContext.OperatorUPN
            } else {
                "admin@$($shortName).onmicrosoft.com"
            }
        } else {
            "AppOnly:$($ClientId)"
        }

        # SRS 3.1.1 step 4 — store module-scoped context
        # _-prefixed fields are internal: used by Get-SPCOrphanedUser for per-site reconnects
        $script:SPCContext = [PSCustomObject]@{
            TenantName             = $shortName
            OperatorUPN            = $operatorUPN
            AuthMethod             = $AuthMethod
            ConnectedAt            = $connectedAt
            GraphTokenRefreshedAt  = $connectedAt
            PnPContext             = $pnpContext
            GraphAccessToken       = $graphToken
            _ClientId              = $ClientId
            _CertificateThumbprint = $CertificateThumbprint
            _CertificatePath       = $CertificatePath
            _CertificatePassword   = $CertificatePassword   # SecureString — never plain text
            _ClientSecret          = $ClientSecret          # SecureString — never plain text
        }

        # SRS 3.1.1 step 5 — pipeline output (sanitized without raw GraphAccessToken)
        $out = [PSCustomObject][ordered]@{
            TenantName       = $shortName
            AuthMethod       = $AuthMethod
            ConnectedAt      = $connectedAt
            ExpiresAt        = $connectedAt.AddHours(1)
            IsGraphConnected = [bool](-not [string]::IsNullOrWhiteSpace($graphToken))
        }
        $out.PSObject.TypeNames.Insert(0, 'SPC.ConnectionInfo')
        $out
    }
}