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