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 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)]
        [AllowEmptyString()]
        [string] $TenantName,

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

        [Parameter()]
        [string] $ClientId,

        [Parameter()]
        [string] $CertificatePath,

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

        [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($CertificatePath) -and $null -eq $ClientSecret)) {
                throw 'ERR-AUTH-003: AppOnly auth requires -ClientId and either -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) 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"
                $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' -NoWelcome |
                    Out-Null

            } elseif (-not [string]::IsNullOrWhiteSpace($CertificatePath)) {
                Write-Verbose "Connecting AppOnly (certificate) 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" }
                $pnpContext = Connect-PnPOnline -Url $adminUrl -ClientId $ClientId `
                    -Tenant $tenantId `
                    -CertificatePath $CertificatePath -CertificatePassword $CertificatePassword `
                    -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

        # 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
            AuthMethod           = $AuthMethod
            ConnectedAt          = $connectedAt
            PnPContext           = $pnpContext
            GraphAccessToken     = $graphToken
            _ClientId            = $ClientId
            _CertificatePath     = $CertificatePath
            _CertificatePassword = $CertificatePassword   # SecureString — never plain text
            _ClientSecret        = $ClientSecret          # SecureString — never plain text
        }

        # SRS 3.1.1 step 5 — pipeline output
        $out = [PSCustomObject][ordered]@{
            TenantName     = $shortName
            AuthMethod     = $AuthMethod
            ConnectedAt    = $connectedAt
            ExpiresAt      = $connectedAt.AddHours(1)
        }
        $out.PSObject.TypeNames.Insert(0, 'SPC.ConnectionInfo')
        $out
    }
}