Public/Authentication.ps1

<#
.SYNOPSIS
    Connects to Microsoft 365 services.
.DESCRIPTION
    This function handles authentication to Microsoft 365 services using Entra PowerShell, Exchange Online, and SharePoint Online.
    It supports interactive authentication with a tenant selector and certificate-based authentication for unattended scripts.
.PARAMETER TenantId
    The ID of the tenant to connect to. If not provided, the function will enter interactive mode.
.PARAMETER AppId
    The App ID of the Azure AD app to use for authentication.
.PARAMETER CertificateThumbprint
    The thumbprint of the certificate to use for authentication.
.EXAMPLE
    Connect-O365
.EXAMPLE
    Connect-O365 -TenantId 'your-tenant-id.onmicrosoft.com' -AppId 'your-app-id' -CertificateThumbprint 'your-certificate-thumbprint'
.NOTES
    This function creates a configuration file at ~/.O365-Toolkit/tenants.json to store tenant information.
    Uses Microsoft.Entra PowerShell module instead of Microsoft.Graph SDK for Entra operations.
#>

function Connect-O365 {
    [CmdletBinding()]
    param(
        [string]$TenantId,
        [string]$AppId,
        [string]$CertificateThumbprint,
        [switch]$AddTenant,
        [switch]$InstallDependencies
    )

    # Ensure Entra module is available and prefer Beta if installed
    if (-not $global:O365_EntraModule) {
        if (Get-Module -ListAvailable -Name 'Microsoft.Entra.Beta') {
            try { 
                Import-Module Microsoft.Entra.Beta -Force -ErrorAction Stop
                $global:O365_EntraModule = 'Microsoft.Entra.Beta'
            } 
            catch {
                Write-Verbose "Failed to load Microsoft.Entra.Beta, falling back to stable..." -Verbose
            }
        }
        if (-not $global:O365_EntraModule -and (Get-Module -ListAvailable -Name 'Microsoft.Entra')) {
            try { 
                Import-Module Microsoft.Entra -Force -ErrorAction Stop
                $global:O365_EntraModule = 'Microsoft.Entra'
            } 
            catch {
                Write-Warning "Microsoft.Entra module could not be loaded. Install with: Install-Module -Name Microsoft.Entra -Scope CurrentUser"
                return
            }
        }
    }

    if (-not $TenantId) {
        # Interactive mode with browser-based authentication
        $ConfigPath = Join-Path -Path $HOME -ChildPath '.O365-Toolkit'
        if (-not (Test-Path -Path $ConfigPath)) {
            New-Item -ItemType Directory -Path $ConfigPath | Out-Null
        }
        $TenantsFile = Join-Path -Path $ConfigPath -ChildPath 'tenants.json'
        $TenantsData = if (Test-Path $TenantsFile) { 
            try { Get-Content -Path $TenantsFile | ConvertFrom-Json } catch { @{ Tenants = @{} } }
        } else { 
            @{ Tenants = @{} } 
        }
        
        # If user wants to add tenant or no tenants exist, use browser login
        if ($AddTenant -or $TenantsData.Tenants.Count -eq 0) {
            Write-Host "`n🔐 Opening browser for authentication..." -ForegroundColor Cyan
            Write-Host "This will allow you to select your tenant from the browser." -ForegroundColor Gray
            
            # Connect using browser - this auto-discovers tenant
            Connect-Entra -TenantId $TenantId -Scopes 'Application.Read.All','Directory.Read.All' | Out-Null
            $context = Get-EntraContext
            
            if ($context) {
                $TenantId = $context.TenantId
                $tenantDisplayName = $context.Environment
                
                # Get tenant name and domain from API when possible
                $tenantName = $TenantId
                $tenantToSave = $TenantId
                try {
                    $org = Get-EntraTenantDetail -ErrorAction Stop
                    if ($org) {
                        if ($org.DisplayName) { $tenantName = $org.DisplayName }
                    }
                    Write-Host "✓ Authenticated to: $tenantName ($TenantId)" -ForegroundColor Green
                } catch {
                    Write-Host "✓ Authenticated to: $TenantId" -ForegroundColor Green
                }

                # Ensure Tenants is a dictionary before adding
                if (-not ($TenantsData.Tenants -is [System.Collections.IDictionary])) {
                    # Convert PSCustomObject properties into a hashtable
                    $h = @{}
                    if ($TenantsData.Tenants) {
                        foreach ($p in $TenantsData.Tenants.PSObject.Properties) { $h[$p.Name] = $p.Value }
                    }
                    $TenantsData.Tenants = $h
                }

                # Save to config (store domain if available, otherwise tenant id)
                $TenantsData.Tenants[$tenantName] = $tenantToSave
                $TenantsData.CurrentTenant = $tenantName
                $TenantsData | ConvertTo-Json | Out-File -FilePath $TenantsFile -Force
                Write-Host "💾 Saved to config for future quick access" -ForegroundColor Yellow
            } else {
                Write-Error "Browser authentication failed or was cancelled"
                return
            }
        }
        elseif ($TenantsData.Tenants.Count -gt 0) {
            # Show saved tenants for quick selection
            Write-Host "`n📋 Saved Tenants:" -ForegroundColor Cyan
            $TenantNames = @($TenantsData.Tenants.Keys | Sort-Object)
            $Choice = 0
            
            for ($i = 0; $i -lt $TenantNames.Count; $i++) {
                Write-Host ("{0}: {1}" -f ($i + 1), $TenantNames[$i])
            }
            Write-Host ("{0}: Add new tenant (browser login)" -f ($TenantNames.Count + 1)) -ForegroundColor Yellow
            
            while ($Choice -lt 1 -or $Choice -gt ($TenantNames.Count + 1)) {
                $Choice = [int](Read-Host "Select (1-$($TenantNames.Count + 1))")
            }

            if ($Choice -eq ($TenantNames.Count + 1)) {
                # Recursive call with -AddTenant flag for browser login
                Connect-O365 -AddTenant
                return
            }
            else {
                $tenantName = $TenantNames[$Choice - 1]
                $TenantId = $TenantsData.Tenants.$tenantName
                $TenantsData.CurrentTenant = $tenantName
                $TenantsData | ConvertTo-Json | Out-File -FilePath $TenantsFile -Force
                Write-Host "✓ Using tenant: $tenantName" -ForegroundColor Green
            }
        }
    }

    try {
        Write-Verbose ("InstallDependencies flag: {0}" -f $InstallDependencies)
        # If requested, attempt to auto-install common dependencies before connecting
        if ($InstallDependencies) {
            Write-Host "🔁 Checking and installing missing dependencies..." -ForegroundColor Cyan
            $dependencyMap = @{
                'ExchangeOnlineManagement' = @{ Cmd = 'Connect-ExchangeOnline' }
                'PnP.PowerShell' = @{ Cmd = 'Connect-PnPOnline' }
                'Microsoft.Online.SharePoint.PowerShell' = @{ Cmd = 'Connect-SPOService' }
            }

            foreach ($mod in $dependencyMap.Keys) {
                $cmdName = $dependencyMap[$mod].Cmd
                $found = Get-Command -Name $cmdName -ErrorAction SilentlyContinue
                if (-not $found) {
                    Write-Host "• Missing module: $mod (cmdlet $cmdName not found). Installing..." -ForegroundColor Yellow
                    try {
                        if (-not (Get-PSRepository -Name 'PSGallery' -ErrorAction SilentlyContinue)) {
                            Register-PSRepository -Name 'PSGallery' -SourceLocation 'https://www.powershellgallery.com/api/v2' -InstallationPolicy Trusted -ErrorAction SilentlyContinue
                        }
                        Install-Module -Name $mod -Scope CurrentUser -Force -AllowClobber -ErrorAction Stop
                        Write-Host " ✓ Installed $mod" -ForegroundColor Green
                    }
                    catch {
                        Write-Warning ("Failed to install {0}: {1}" -f $mod, $_.Exception.Message)
                        Write-Host ("You can install it manually: Install-Module -Name {0} -Scope CurrentUser" -f $mod) -ForegroundColor Yellow
                    }
                }
                else {
                    Write-Verbose "Dependency present: $mod"
                }
            }
        }
        if ($AppId -and $CertificateThumbprint) {
            Write-Verbose "Connecting to Entra using certificate..."
            Connect-Entra -TenantId $TenantId -AppId $AppId -CertificateThumbprint $CertificateThumbprint -Scopes 'Application.Read.All','Directory.Read.All'
            Write-Verbose "Connecting to Exchange Online using certificate..."
                Connect-ExchangeOnline -AppId $AppId -CertificateThumbprint $CertificateThumbprint -Organization $TenantId -Interactive
            $SPOAdminUrl = "https://$($TenantId.Split('.')[0])-admin.sharepoint.com"
            Write-Verbose "Connecting to SharePoint Online Admin using certificate..."
            Connect-SPOService -Url $SPOAdminUrl -AppId $AppId -CertificateThumbprint $CertificateThumbprint
        }
        else {
            Write-Verbose "Connecting to Entra for tenant: $TenantId"
            Connect-Entra -TenantId $TenantId -Scopes 'Application.Read.All','Directory.Read.All'
            Write-Verbose "Deferring Exchange Online and SharePoint Online connections until required. Use Ensure-ExchangeConnected or Ensure-SPOConnected to connect when needed."
            # Mark that Entra is connected; EXO and SPO will be connected lazily when required.
            $global:O365ToolkitConnection = @{ TenantId = $TenantId; EntraConnection = $true; EXOConnection = $false; SPOConnection = $false }
        }

        Write-Verbose "Connection successful."
        $global:O365ToolkitConnection = @{
            TenantId = $TenantId
            EntraConnection = $true
            EXOConnection = $true
            SPOConnection = $true
        }
    }
    catch {
        Write-Warning "Failed to connect. Error: $_"
    }
}

<#
.SYNOPSIS
    Disconnects from Microsoft 365 services.
.DESCRIPTION
    This function disconnects from all connected Microsoft 365 services using Entra and other connectors.
.EXAMPLE
    Disconnect-O365
#>

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

    Write-Verbose "Disconnecting from all services..."
    try {
        Disconnect-Entra -ErrorAction SilentlyContinue
        if ($global:O365ToolkitConnection -and $global:O365ToolkitConnection.EXOConnection) {
            try { Disconnect-ExchangeOnline -Confirm:$false -ErrorAction SilentlyContinue } catch {}
        }
        if ($global:O365ToolkitConnection -and $global:O365ToolkitConnection.SPOConnection) {
            try { Disconnect-SPOService -ErrorAction SilentlyContinue } catch {}
        }
        $global:O365ToolkitConnection = $null
        Write-Verbose "Disconnection successful."
    }
    catch {
        Write-Warning "Failed to disconnect. Error: $_"
    }
}

<#
.SYNOPSIS
    Gets the configured tenants.
.DESCRIPTION
    This function lists the tenants that have been configured for use with the toolkit.
.EXAMPLE
    Get-O365Tenant
#>

function Get-O365Tenant {
    [CmdletBinding()]
    param()

    $ConfigPath = Join-Path -Path $HOME -ChildPath '.O365-Toolkit'
    $TenantsFile = Join-Path -Path $ConfigPath -ChildPath 'tenants.json'
    if (Test-Path $TenantsFile) {
        $TenantsData = Get-Content -Path $TenantsFile | ConvertFrom-Json
        return $TenantsData
    }
    else {
        Write-Verbose "No tenants configured."
    }
}

<#
.SYNOPSIS
    Sets the current tenant.
.DESCRIPTION
    This function sets the current tenant for the toolkit to use for subsequent connections.
.PARAMETER TenantName
    The friendly name of the tenant to set as current.
.EXAMPLE
    Set-O365CurrentTenant -TenantName 'My Test Tenant'
#>

function Set-O365CurrentTenant {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory=$true)]
        [string]$TenantName
    )

    $ConfigPath = Join-Path -Path $HOME -ChildPath '.O365-Toolkit'
    $TenantsFile = Join-Path -Path $ConfigPath -ChildPath 'tenants.json'
    if (Test-Path $TenantsFile) {
        $TenantsData = Get-Content -Path $TenantsFile | ConvertFrom-Json
        if ($TenantsData.Tenants.Contains($TenantName)) {
            $TenantsData.CurrentTenant = $TenantName
            $TenantsData | ConvertTo-Json | Out-File -FilePath $TenantsFile
            Write-Verbose "Current tenant set to: $TenantName"
        }
        else {
            Write-Warning "Tenant not found: $TenantName"
        }
    }
    else {
        Write-Warning "No tenants configured."
    }
}

function Ensure-ExchangeConnected {
    [CmdletBinding()]
    param(
        [switch]$Force
    )

    if (-not $global:O365ToolkitConnection) {
        Write-Verbose "No toolkit connection state found. Call Connect-O365 first." -Verbose
        return $false
    }

    if ($global:O365ToolkitConnection.EXOConnection -and -not $Force) {
        Write-Verbose "Exchange Online already connected." -Verbose
        return $true
    }

    # Try to reuse an access token from Entra context (best-effort). If not available, fall back to device flow.
    $context = $null
    try { $context = Get-EntraContext -ErrorAction SilentlyContinue } catch {}

    # locate cache file for this tenant
    $cacheDir = Join-Path -Path $HOME -ChildPath '.O365-Toolkit'
    if (-not (Test-Path $cacheDir)) { New-Item -ItemType Directory -Path $cacheDir | Out-Null }
    $tenantKey = $global:O365ToolkitConnection.TenantId -replace '[^a-zA-Z0-9]','_'
    $exoCacheFile = Join-Path $cacheDir -ChildPath ("exo_cache_{0}.json" -f $tenantKey)

    # If we have an Entra access token, try using it first
    if ($context -and $context.AccessToken) {
        try {
            Write-Verbose "Attempting Exchange connect using Entra access token." -Verbose
            Connect-ExchangeOnline -AccessToken $context.AccessToken -Organization $global:O365ToolkitConnection.TenantId -ShowBanner:$false
            $global:O365ToolkitConnection.EXOConnection = $true
            # cache successful auth
            try { @{ LastAuth = (Get-Date).ToString('o'); Method = 'AccessToken' } | ConvertTo-Json | Out-File -FilePath $exoCacheFile -Force } catch {}
            return $true
        }
        catch {
            Write-Verbose "Connect-ExchangeOnline with AccessToken failed: $($_.Exception.Message)" -Verbose
        }
    }
    # If cache exists and is recent, try -DisableWAM first to avoid device prompt
    if (Test-Path $exoCacheFile) {
        try {
            $cache = Get-Content -Path $exoCacheFile | ConvertFrom-Json
            $last = Get-Date $cache.LastAuth
            if ((Get-Date) - $last -lt (New-TimeSpan -Hours 24)) {
                Write-Verbose "Recent EXO auth found (within 24h). Trying -DisableWAM to reuse session." -Verbose
                try {
                    Connect-ExchangeOnline -UserPrincipalName (Get-EntraContext).Account -DisableWAM -ShowBanner:$false
                    $global:O365ToolkitConnection.EXOConnection = $true
                    return $true
                }
                catch {
                    Write-Verbose "Retry with -DisableWAM failed: $($_.Exception.Message)" -Verbose
                }
            }
        } catch {}
    }

    # Fallback to device code flow to authenticate Exchange if necessary
    Write-Host "Exchange Online authentication required. Opening device code flow..." -ForegroundColor Yellow
    try {
        Connect-ExchangeOnline -Device -Organization $global:O365ToolkitConnection.TenantId -ShowBanner:$false
        $global:O365ToolkitConnection.EXOConnection = $true
        # cache successful device auth
        try { @{ LastAuth = (Get-Date).ToString('o'); Method = 'Device' } | ConvertTo-Json | Out-File -FilePath $exoCacheFile -Force } catch {}
        return $true
    }
    catch {
        Write-Warning "Exchange Online connection failed: $($_.Exception.Message)"
        return $false
    }
}

function Ensure-SPOConnected {
    [CmdletBinding()]
    param(
        [switch]$Force
    )

    if (-not $global:O365ToolkitConnection) {
        Write-Verbose "No toolkit connection state found. Call Connect-O365 first." -Verbose
        return $false
    }

    if ($global:O365ToolkitConnection.SPOConnection -and -not $Force) {
        Write-Verbose "SharePoint Online already connected." -Verbose
        return $true
    }

    # Determine admin URL: try to use stored tenant domain, otherwise query organization
    $spoAdminUrl = $null
    $tenantInfo = $global:O365ToolkitConnection
    if ($tenantInfo -and $tenantInfo.TenantId) {
        $maybeDomain = $tenantInfo.TenantId
        if ($maybeDomain -match '\.') {
            $spoAdminUrl = "https://$($maybeDomain.Split('.')[0])-admin.sharepoint.com"
        }
    }

    if (-not $spoAdminUrl) {
        try {
            $org = Get-EntraTenantDetail -ErrorAction SilentlyContinue
            if ($org) {
                $tenantPrefix = $org.TenantId.Split('.')[0]
                $spoAdminUrl = "https://$($tenantPrefix)-admin.sharepoint.com"
            }
        }
        catch {}
    }

    if (-not $spoAdminUrl) { Write-Warning "Could not determine SharePoint admin URL for tenant."; return $false }

    try {
        Write-Verbose "Connecting to SharePoint Online Admin: $spoAdminUrl" -Verbose
        Connect-SPOService -Url $spoAdminUrl
        $global:O365ToolkitConnection.SPOConnection = $true
        return $true
    }
    catch {
        Write-Warning "Connect-SPOService failed: $($_.Exception.Message)"
        return $false
    }
}