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 } } |