M365CertAuth.psm1
|
# Certificate Authentication Module for Microsoft 365 #Requires -Modules Microsoft.Graph.Authentication, Microsoft.Graph.Applications, Microsoft.Graph.Identity.DirectoryManagement # Module-level variables for tenant storage $script:TenantStoragePath = "$env:APPDATA\M365CertAuth\tenants.json" $script:CachedTenants = $null # Load required assemblies for encryption Add-Type -AssemblyName System.Security function Get-StoredTenants { <# .SYNOPSIS Retrieves stored tenant information from local storage .DESCRIPTION Loads tenant information from encrypted local storage file #> if ($null -ne $script:CachedTenants) { return $script:CachedTenants } if (-not (Test-Path $script:TenantStoragePath)) { return @() } try { $encryptedData = Get-Content $script:TenantStoragePath -Raw if ([string]::IsNullOrWhiteSpace($encryptedData)) { return @() } $decryptedData = [System.Text.Encoding]::UTF8.GetString([System.Security.Cryptography.ProtectedData]::Unprotect([Convert]::FromBase64String($encryptedData), $null, [System.Security.Cryptography.DataProtectionScope]::CurrentUser)) $parsedData = ConvertFrom-Json $decryptedData # Store as array and return as array if ($parsedData -is [array]) { $script:CachedTenants = $parsedData } else { $script:CachedTenants = @($parsedData) } return $script:CachedTenants } catch { Write-Warning "Failed to load stored tenants: $($_.Exception.Message)" return @() } } function Save-TenantInfo { <# .SYNOPSIS Saves tenant information to encrypted local storage .DESCRIPTION Encrypts and stores tenant information for future use .PARAMETER TenantInfo Hashtable containing tenant information (TenantId, TenantName, PrimaryDomain, etc.) #> param( [Parameter(Mandatory = $true)] [hashtable]$TenantInfo ) try { # Validate required fields if (-not $TenantInfo.TenantId -or -not $TenantInfo.PrimaryDomain) { Write-Warning "Invalid tenant information: TenantId and PrimaryDomain are required" return } # Ensure storage directory exists $storageDir = Split-Path $script:TenantStoragePath -Parent if (-not (Test-Path $storageDir)) { New-Item -Path $storageDir -ItemType Directory -Force | Out-Null } # Load existing tenants - force fresh read to avoid cache issues $script:CachedTenants = $null $tenants = @(Get-StoredTenants) # Remove any existing entries for this tenant (by TenantId) $tenants = $tenants | Where-Object { $_.TenantId -ne $TenantInfo.TenantId } # Add updated tenant info with timestamp $TenantInfo.LastUsed = (Get-Date).ToString('yyyy-MM-dd HH:mm:ss') # Create new array with the updated tenant $updatedTenants = @($tenants) + @($TenantInfo) # Sort tenants by most recently used (no enforced limit) $finalTenants = $updatedTenants | Sort-Object LastUsed -Descending # Ensure we have a proper array for JSON serialization if ($finalTenants.Count -eq 1) { $jsonData = ConvertTo-Json @($finalTenants) -Depth 3 } else { $jsonData = ConvertTo-Json $finalTenants -Depth 3 } # Encrypt and save $encryptedData = [Convert]::ToBase64String([System.Security.Cryptography.ProtectedData]::Protect([System.Text.Encoding]::UTF8.GetBytes($jsonData), $null, [System.Security.Cryptography.DataProtectionScope]::CurrentUser)) Set-Content -Path $script:TenantStoragePath -Value $encryptedData -Force # Update cache with final tenants $script:CachedTenants = $finalTenants } catch { Write-Warning "Failed to save tenant information: $($_.Exception.Message)" # Clear cache on error to force reload next time $script:CachedTenants = $null } } function Select-Tenant { <# .SYNOPSIS Prompts user to select from stored tenants or add a new one .DESCRIPTION Displays a menu of stored tenants and allows user to select one or add a new tenant .PARAMETER AllowNew Whether to allow adding a new tenant #> param( [switch]$AllowNew ) # Default AllowNew to true if not specified if (-not $PSBoundParameters.ContainsKey('AllowNew')) { $AllowNew = $true } $storedTenants = @(Get-StoredTenants) if ($storedTenants.Count -eq 0 -and -not $AllowNew) { return $null } # Calculate box width $boxWidth = 60 Write-Host "" Write-Host ("╔" + ("═" * ($boxWidth - 2)) + "╗") -ForegroundColor Cyan Write-Host ("║" + (" " * (($boxWidth - 16) / 2)) + "Tenant Selection" + (" " * (($boxWidth - 16) / 2)) + "║") -ForegroundColor Cyan Write-Host ("╚" + ("═" * ($boxWidth - 2)) + "╝") -ForegroundColor Cyan Write-Host "" $tenantStartIndex = 1 if ($AllowNew) { Write-Host " " -NoNewline Write-Host "[1]" -ForegroundColor Green -NoNewline Write-Host " " -NoNewline Write-Host "Add new tenant" -ForegroundColor Green $tenantStartIndex = 2 } if ($storedTenants.Count -gt 0) { Write-Host "" Write-Host " Stored Tenants " -NoNewline -ForegroundColor Yellow Write-Host "($($storedTenants.Count) found)" -ForegroundColor DarkGray Write-Host (" " + ("─" * 50)) -ForegroundColor DarkGray for ($i = 0; $i -lt $storedTenants.Count; $i++) { $tenant = $storedTenants[$i] $optionNumber = $tenantStartIndex + $i $hasLastUsed = $null -ne $tenant.LastUsed Write-Host " " -NoNewline Write-Host "[$optionNumber]" -ForegroundColor Cyan -NoNewline Write-Host " " -NoNewline Write-Host $tenant.PrimaryDomain -ForegroundColor White -NoNewline Write-Host " ─ " -ForegroundColor DarkGray -NoNewline Write-Host $tenant.TenantName -ForegroundColor Gray if ($hasLastUsed) { $datePart = $tenant.LastUsed.Split(' ')[0] Write-Host " " -NoNewline Write-Host "└─" -ForegroundColor DarkGray -NoNewline Write-Host " Last used: $datePart" -ForegroundColor DarkGray } } } elseif (-not $AllowNew) { Write-Host " No stored tenants found." -ForegroundColor Yellow Write-Host "" return $null } else { Write-Host "" Write-Host " No stored tenants found. You'll need to add a new tenant." -ForegroundColor Yellow } Write-Host "" Write-Host (" " + ("─" * 50)) -ForegroundColor DarkGray Write-Host " " -NoNewline Write-Host "[0]" -ForegroundColor Red -NoNewline Write-Host " Cancel" -ForegroundColor Red Write-Host "" do { Write-Host " " -NoNewline $selection = Read-Host "Select option" if ($selection -eq "0") { return $null } $selectionNum = $null if ([int]::TryParse($selection, [ref]$selectionNum)) { if ($AllowNew -and $selectionNum -eq 1) { return "NEW" } $tenantIndex = $selectionNum - $tenantStartIndex if ($tenantIndex -ge 0 -and $tenantIndex -lt $storedTenants.Count) { return $storedTenants[$tenantIndex] } } Write-Host " " -NoNewline Write-Host "Invalid selection. Please try again." -ForegroundColor Red Write-Host "" } while ($true) } function Remove-StoredTenant { <# .SYNOPSIS Removes a stored tenant from local storage .DESCRIPTION Allows user to select and remove stored tenant information #> $storedTenants = @(Get-StoredTenants) if ($storedTenants.Count -eq 0) { Write-Host "" Write-Host " No stored tenants to remove." -ForegroundColor Yellow Write-Host "" return } $boxWidth = 60 Write-Host "" Write-Host ("╔" + ("═" * ($boxWidth - 2)) + "╗") -ForegroundColor Red Write-Host ("║" + (" " * (($boxWidth - 20) / 2)) + "Remove Stored Tenant" + (" " * (($boxWidth - 20) / 2)) + "║") -ForegroundColor Red Write-Host ("╚" + ("═" * ($boxWidth - 2)) + "╝") -ForegroundColor Red Write-Host "" for ($i = 0; $i -lt $storedTenants.Count; $i++) { $tenant = $storedTenants[$i] Write-Host " " -NoNewline Write-Host "[$($i + 1)]" -ForegroundColor Red -NoNewline Write-Host " " -NoNewline Write-Host $tenant.PrimaryDomain -ForegroundColor White -NoNewline Write-Host " ─ " -ForegroundColor DarkGray -NoNewline Write-Host $tenant.TenantName -ForegroundColor Gray } Write-Host "" Write-Host (" " + ("─" * 50)) -ForegroundColor DarkGray Write-Host " " -NoNewline Write-Host "[0]" -ForegroundColor Cyan -NoNewline Write-Host " Cancel" -ForegroundColor Cyan Write-Host "" do { Write-Host " " -NoNewline $selection = Read-Host "Select tenant to remove" if ($selection -eq "0") { return } $selectionNum = $null if ([int]::TryParse($selection, [ref]$selectionNum) -and $selectionNum -ge 1 -and $selectionNum -le $storedTenants.Count) { $tenantToRemove = $storedTenants[$selectionNum - 1] Write-Host "" Write-Host " " -NoNewline Write-Host "Remove tenant " -NoNewline -ForegroundColor Yellow Write-Host "'$($tenantToRemove.PrimaryDomain)'" -NoNewline -ForegroundColor White Write-Host " ─ " -NoNewline -ForegroundColor DarkGray Write-Host "$($tenantToRemove.TenantName)" -NoNewline -ForegroundColor Gray Write-Host "?" -NoNewline -ForegroundColor Yellow Write-Host "" Write-Host " " -NoNewline $confirmation = Read-Host "(y/N)" if ($confirmation -eq 'y' -or $confirmation -eq 'Y') { $remainingTenants = $storedTenants | Where-Object { $_.TenantId -ne $tenantToRemove.TenantId } if ($remainingTenants.Count -eq 0) { # Remove file if no tenants left if (Test-Path $script:TenantStoragePath) { Remove-Item $script:TenantStoragePath -Force } } else { # Save remaining tenants $jsonData = ConvertTo-Json $remainingTenants -Depth 3 $encryptedData = [Convert]::ToBase64String([System.Security.Cryptography.ProtectedData]::Protect([System.Text.Encoding]::UTF8.GetBytes($jsonData), $null, [System.Security.Cryptography.DataProtectionScope]::CurrentUser)) Set-Content -Path $script:TenantStoragePath -Value $encryptedData -Force } # Update cache $script:CachedTenants = $remainingTenants Write-Host "" Write-Host " " -NoNewline Write-Host "Tenant removed successfully." -ForegroundColor Green Write-Host "" } return } else { Write-Host " " -NoNewline Write-Host "Invalid selection. Please try again." -ForegroundColor Red Write-Host "" } } while ($true) } function Show-StoredTenants { <# .SYNOPSIS Displays all stored tenant information .DESCRIPTION Shows a formatted list of all stored tenants with their details #> $storedTenants = @(Get-StoredTenants) if ($storedTenants.Count -eq 0) { Write-Host "" Write-Host " No stored tenants found." -ForegroundColor Yellow Write-Host "" return } $boxWidth = 70 Write-Host "" Write-Host ("╔" + ("═" * ($boxWidth - 2)) + "╗") -ForegroundColor Cyan Write-Host ("║" + (" " * (($boxWidth - 15) / 2)) + "Stored Tenants" + (" " * (($boxWidth - 15) / 2)) + "║") -ForegroundColor Cyan Write-Host ("╚" + ("═" * ($boxWidth - 2)) + "╝") -ForegroundColor Cyan Write-Host "" Write-Host " Found " -NoNewline -ForegroundColor Yellow Write-Host "$($storedTenants.Count)" -NoNewline -ForegroundColor White Write-Host " stored tenant(s)" -ForegroundColor Yellow Write-Host "" $sortedTenants = $storedTenants | Sort-Object LastUsed -Descending $index = 0 foreach ($tenant in $sortedTenants) { $index++ Write-Host (" ┌─ Tenant #$index ─" + ("─" * 45)) -ForegroundColor DarkGray Write-Host (" │") -ForegroundColor DarkGray Write-Host (" │") -NoNewline -ForegroundColor DarkGray Write-Host " Primary Domain: " -NoNewline -ForegroundColor Gray Write-Host $tenant.PrimaryDomain -ForegroundColor White Write-Host (" │") -NoNewline -ForegroundColor DarkGray Write-Host " Tenant Name: " -NoNewline -ForegroundColor Gray Write-Host $tenant.TenantName -ForegroundColor White Write-Host (" │") -NoNewline -ForegroundColor DarkGray Write-Host " Tenant ID: " -NoNewline -ForegroundColor Gray Write-Host $tenant.TenantId -ForegroundColor Cyan Write-Host (" │") -NoNewline -ForegroundColor DarkGray Write-Host " SharePoint: " -NoNewline -ForegroundColor Gray Write-Host $tenant.SharePointTenantName -ForegroundColor White Write-Host (" │") -NoNewline -ForegroundColor DarkGray Write-Host " App ID: " -NoNewline -ForegroundColor Gray Write-Host $tenant.AppId -ForegroundColor Cyan Write-Host (" │") -NoNewline -ForegroundColor DarkGray Write-Host " Last Used: " -NoNewline -ForegroundColor Gray Write-Host $tenant.LastUsed -ForegroundColor Yellow Write-Host (" │") -ForegroundColor DarkGray if ($index -lt $sortedTenants.Count) { Write-Host (" ├" + ("─" * 50)) -ForegroundColor DarkGray } else { Write-Host (" └" + ("─" * 50)) -ForegroundColor DarkGray } Write-Host "" } } function Clear-StoredTenants { <# .SYNOPSIS Clears all stored tenant information .DESCRIPTION Removes all stored tenant information from local storage after confirmation #> $storedTenants = @(Get-StoredTenants) if ($storedTenants.Count -eq 0) { Write-Host "" Write-Host " No stored tenants to clear." -ForegroundColor Yellow Write-Host "" return } $boxWidth = 60 Write-Host "" Write-Host ("╔" + ("═" * ($boxWidth - 2)) + "╗") -ForegroundColor Red Write-Host ("║" + (" " * (($boxWidth - 25) / 2)) + "Clear All Stored Tenants" + (" " * (($boxWidth - 25) / 2)) + "║") -ForegroundColor Red Write-Host ("╚" + ("═" * ($boxWidth - 2)) + "╝") -ForegroundColor Red Write-Host "" Write-Host " This will remove " -NoNewline -ForegroundColor Yellow Write-Host "$($storedTenants.Count)" -NoNewline -ForegroundColor White Write-Host " stored tenant(s):" -ForegroundColor Yellow Write-Host "" Write-Host (" " + ("─" * 50)) -ForegroundColor DarkGray foreach ($tenant in $storedTenants) { Write-Host " " -NoNewline Write-Host "• " -ForegroundColor Red -NoNewline Write-Host $tenant.PrimaryDomain -ForegroundColor White -NoNewline Write-Host " ─ " -ForegroundColor DarkGray -NoNewline Write-Host $tenant.TenantName -ForegroundColor Gray } Write-Host (" " + ("─" * 50)) -ForegroundColor DarkGray Write-Host "" Write-Host " " -NoNewline $confirmation = Read-Host "Are you sure you want to clear all stored tenants? (y/N)" if ($confirmation -eq 'y' -or $confirmation -eq 'Y') { if (Test-Path $script:TenantStoragePath) { Remove-Item $script:TenantStoragePath -Force } $script:CachedTenants = @() Write-Host "" Write-Host " " -NoNewline Write-Host "All stored tenants cleared successfully." -ForegroundColor Green Write-Host "" } else { Write-Host "" Write-Host " " -NoNewline Write-Host "Operation cancelled." -ForegroundColor Yellow Write-Host "" } } function Get-CertificateForAuth { <# .SYNOPSIS Gets or creates a certificate for Azure authentication .DESCRIPTION Handles certificate creation, app registration, permission assignment, and returns certificate for connections. Now supports tenant selection from stored tenants for improved user experience. .NOTES Automatically connects to Microsoft Graph with required scopes if not already connected .PARAMETER AppName Name of the Azure AD app registration .PARAMETER CertSubject Certificate subject name .PARAMETER TenantId Specific tenant ID to use (bypasses tenant selection) .PARAMETER UseTenantSelection Whether to prompt for tenant selection from stored tenants (default: $true) .EXAMPLE $auth = Get-CertificateForAuth Connect-PnPOnline -Url $url -ClientId $auth.AppId -Tenant $auth.TenantId -CertificateBase64Encoded $auth.CertificateBase64 .EXAMPLE $auth = Get-CertificateForAuth -TenantId "12345678-1234-1234-1234-123456789012" # Uses specific tenant ID, bypasses selection #> param( [string]$AppName = "M365 PowerShell App", [string]$CertSubject = "CN=M365 PowerShell", [string]$TenantId = $null, [bool]$UseTenantSelection = $true ) try { # Ensure clean session - disconnect any existing Graph connection $existingContext = Get-MgContext -ErrorAction SilentlyContinue if ($existingContext) { Disconnect-MgGraph -ErrorAction SilentlyContinue | Out-Null } # Handle tenant selection if enabled and no specific tenant provided $selectedTenantInfo = $null $useStoredTenant = $false # If TenantId is provided, look up stored tenant info if (-not [string]::IsNullOrEmpty($TenantId)) { $storedTenants = @(Get-StoredTenants) $selectedTenantInfo = $storedTenants | Where-Object { $_.TenantId -eq $TenantId } | Select-Object -First 1 if ($selectedTenantInfo) { $useStoredTenant = $true Write-Host "Using stored tenant: $($selectedTenantInfo.PrimaryDomain) - $($selectedTenantInfo.TenantName)" -ForegroundColor Green } else { Write-Host "Tenant ID provided but not found in stored tenants. Will use interactive setup." -ForegroundColor Yellow } } elseif ($UseTenantSelection) { $selectedTenant = Select-Tenant -AllowNew if ($null -eq $selectedTenant) { throw "No tenant selected. Operation cancelled." } if ($selectedTenant -eq "NEW") { Write-Host "You'll be prompted to log in twice - once for identity, once for admin consent" -ForegroundColor Yellow # Continue with normal flow to add new tenant } else { $selectedTenantInfo = $selectedTenant $TenantId = $selectedTenantInfo.TenantId $useStoredTenant = $true Write-Host "Using stored tenant: $($selectedTenantInfo.PrimaryDomain) - $($selectedTenantInfo.TenantName)" -ForegroundColor Green } } # Establish Graph connection with required scopes try { # If we have stored tenant info with certificate details, try certificate auth directly first if ($useStoredTenant -and $selectedTenantInfo.AppId -and $selectedTenantInfo.Thumbprint) { Write-Host "Connecting to Microsoft Graph using stored certificate for tenant $($selectedTenantInfo.TenantName)..." -ForegroundColor Yellow # Verify the certificate still exists $storedCert = Get-ChildItem -Path "Cert:\CurrentUser\My\$($selectedTenantInfo.Thumbprint)" -ErrorAction SilentlyContinue if ($storedCert) { try { Connect-MgGraph -ClientId $selectedTenantInfo.AppId -TenantId $selectedTenantInfo.TenantId -CertificateThumbprint $selectedTenantInfo.Thumbprint -NoWelcome -ErrorAction Stop | Out-Null Write-Host "Connected to Microsoft Graph using stored certificate" -ForegroundColor Green # Skip the rest of the certificate setup since we're already connected $cert = $storedCert $app = @{ AppId = $selectedTenantInfo.AppId } $actualTenantId = $selectedTenantInfo.TenantId $primaryDomain = $selectedTenantInfo.PrimaryDomain $initialDomain = $selectedTenantInfo.InitialDomain $tenantName = $selectedTenantInfo.TenantName $sharePointTenantName = $selectedTenantInfo.SharePointTenantName # Quick API test $null = Get-MgUser -Top 1 -ErrorAction Stop Write-Host "- Certificate authentication ready" -ForegroundColor Green # Update last used timestamp Save-TenantInfo -TenantInfo @{ TenantId = $selectedTenantInfo.TenantId TenantName = $selectedTenantInfo.TenantName PrimaryDomain = $selectedTenantInfo.PrimaryDomain InitialDomain = $selectedTenantInfo.InitialDomain SharePointTenantName = $selectedTenantInfo.SharePointTenantName AppId = $selectedTenantInfo.AppId AppName = $selectedTenantInfo.AppName CertSubject = $selectedTenantInfo.CertSubject Thumbprint = $selectedTenantInfo.Thumbprint } # Export certificate with private key as base64 for PnP PowerShell $tempPassword = ConvertTo-SecureString -String "TempPassword123!" -Force -AsPlainText $tempPfxPath = [System.IO.Path]::Combine([System.IO.Path]::GetTempPath(), "temp_cert_$($cert.Thumbprint).pfx") try { # Cross-platform certificate export if ($PSVersionTable.PSVersion.Major -ge 6) { # PowerShell 6+ (Core/7) - use .NET method $pfxBytes = $cert.Export([System.Security.Cryptography.X509Certificates.X509ContentType]::Pkcs12, $tempPassword) $certBase64 = [Convert]::ToBase64String($pfxBytes) } else { # Windows PowerShell 5.1 - use cmdlet Export-PfxCertificate -Cert $cert -FilePath $tempPfxPath -Password $tempPassword -Force | Out-Null $pfxBytes = [System.IO.File]::ReadAllBytes($tempPfxPath) $certBase64 = [Convert]::ToBase64String($pfxBytes) Remove-Item $tempPfxPath -Force -ErrorAction SilentlyContinue } } catch { Write-Host " ! Could not export certificate with private key, using public key only" -ForegroundColor Yellow $certBase64 = [Convert]::ToBase64String($cert.RawData) $tempPassword = $null } # Return early with stored tenant info return @{ Certificate = $cert AppId = $selectedTenantInfo.AppId TenantId = $selectedTenantInfo.TenantId Thumbprint = $cert.Thumbprint CertificateBase64 = $certBase64 CertificatePassword = $tempPassword TenantName = $selectedTenantInfo.TenantName SharePointTenantName = $selectedTenantInfo.SharePointTenantName PrimaryDomain = $selectedTenantInfo.PrimaryDomain InitialDomain = $selectedTenantInfo.InitialDomain } } catch { # Reset variables to force normal flow, but preserve TenantId if it was provided $useStoredTenant = $false $selectedTenantInfo = $null # Don't reset TenantId here - preserve it for fallback flow Write-Host "Stored certificate authentication failed, falling back to normal flow..." -ForegroundColor Yellow } } else { # Reset variables to force normal flow, but preserve TenantId if it was provided $useStoredTenant = $false $selectedTenantInfo = $null # Don't reset TenantId here - preserve it for fallback flow Write-Host "Stored certificate not found, falling back to normal flow..." -ForegroundColor Yellow } } # Normal interactive authentication flow (only if stored cert auth failed or not available) $context = Get-MgContext -ErrorAction SilentlyContinue $needsConnection = $false $needsSpecificTenant = $false if (-not $context) { $needsConnection = $true } else { # Check if we're connected to the right tenant if ($TenantId -and $context.TenantId -ne $TenantId) { $needsConnection = $true $needsSpecificTenant = $true } # Check for required scopes $requiredScopes = @("Application.ReadWrite.All", "AppRoleAssignment.ReadWrite.All", "Directory.Read.All", "RoleManagement.ReadWrite.Directory") $missingScopes = @() foreach ($scope in $requiredScopes) { if ($context.Scopes -notcontains $scope) { $missingScopes += $scope } } if ($missingScopes.Count -gt 0) { $needsConnection = $true } } if ($needsConnection) { if ($needsSpecificTenant -or $TenantId) { Write-Host "Connecting to Microsoft Graph for tenant $TenantId..." -ForegroundColor Yellow Connect-MgGraph -TenantId $TenantId -Scopes @("Application.ReadWrite.All", "AppRoleAssignment.ReadWrite.All", "Directory.Read.All", "RoleManagement.ReadWrite.Directory") -NoWelcome -ErrorAction Stop | Out-Null } else { Write-Host "Connecting to Microsoft Graph..." -ForegroundColor Yellow Connect-MgGraph -Scopes @("Application.ReadWrite.All", "AppRoleAssignment.ReadWrite.All", "Directory.Read.All", "RoleManagement.ReadWrite.Directory") -NoWelcome -ErrorAction Stop | Out-Null } Write-Host "Connected to Microsoft Graph" -ForegroundColor Green } } catch { throw "Failed to connect to Microsoft Graph: $($_.Exception.Message)" } # Validate and select best certificate $allMatchingCerts = Get-ChildItem -Path "Cert:\CurrentUser\My" | Where-Object { $_.Subject -eq $CertSubject } $validCerts = @() foreach ($cert in $allMatchingCerts) { $certStatus = @{ Certificate = $cert IsValid = $true Issues = @() DaysUntilExpiry = ($cert.NotAfter - (Get-Date)).Days HasPrivateKey = $false IsRegisteredInAzure = $false } # Check expiry if ($certStatus.DaysUntilExpiry -le 0) { $certStatus.Issues += "Certificate expired on $($cert.NotAfter.ToString('yyyy-MM-dd HH:mm'))" $certStatus.IsValid = $false } elseif ($certStatus.DaysUntilExpiry -le 30) { $certStatus.Issues += "Certificate expires soon ($($certStatus.DaysUntilExpiry) days)" } # Validate private key try { if ($cert.HasPrivateKey) { $rsaCert = [System.Security.Cryptography.X509Certificates.RSACertificateExtensions]::GetRSAPrivateKey($cert) if ($rsaCert) { $certStatus.HasPrivateKey = $true } else { $certStatus.Issues += "Private key not accessible" $certStatus.IsValid = $false } } else { $certStatus.Issues += "No private key available" $certStatus.IsValid = $false } } catch { $certStatus.Issues += "Private key validation failed: $($_.Exception.Message)" $certStatus.IsValid = $false } $validCerts += $certStatus } # Select best certificate $bestCert = $validCerts | Where-Object { $_.IsValid -and $_.HasPrivateKey -and $_.DaysUntilExpiry -gt 0 } | Sort-Object @{Expression = {$_.IsRegisteredInAzure}; Descending = $true}, @{Expression = {$_.DaysUntilExpiry}; Descending = $true} | Select-Object -First 1 if (-not $bestCert) { # Clean up expired certificates silently $expiredCerts = $validCerts | Where-Object { -not $_.IsValid } foreach ($expiredCert in $expiredCerts) { try { Remove-Item -Path "Cert:\CurrentUser\My\$($expiredCert.Certificate.Thumbprint)" -Force -ErrorAction SilentlyContinue } catch {} } # Create new certificate $cert = New-SelfSignedCertificate -Subject $CertSubject -CertStoreLocation "Cert:\CurrentUser\My" -KeyExportPolicy Exportable -KeySpec Signature -KeyLength 2048 -KeyAlgorithm RSA -HashAlgorithm SHA256 -NotAfter (Get-Date).AddYears(2) Write-Host "- Certificate created: $($cert.Thumbprint)" -ForegroundColor Green } else { $cert = $bestCert.Certificate if ($bestCert.Issues.Count -gt 0) { Write-Host "! Certificate issues: $($bestCert.Issues -join '; ')" -ForegroundColor Yellow } Write-Host "- Certificate ready: $($cert.Thumbprint) (expires in $($bestCert.DaysUntilExpiry) days)" -ForegroundColor Green } # Get or create app registration try { $app = Get-MgApplication -Filter "displayName eq '$AppName'" -ErrorAction SilentlyContinue | Select-Object -First 1 } catch { Write-Host "Failed to query applications: $($_.Exception.Message)" -ForegroundColor Red throw "Cannot proceed without ability to query/create app registrations" } if (-not $app) { Write-Host "Creating app registration and permissions..." -ForegroundColor Yellow $app = New-MgApplication -DisplayName $AppName $servicePrincipal = New-MgServicePrincipal -AppId $app.AppId # Wait for service principal propagation Start-Sleep -Seconds 10 # Assign Microsoft Graph permissions try { $graphServicePrincipal = Get-MgServicePrincipal -Filter "displayName eq 'Microsoft Graph'" -ErrorAction Stop $graphAppRoles = @( "62a82d76-70ea-41e2-9197-370581804d09", # Group.ReadWrite.All "741f803b-c850-494e-b5df-cde7c675a1ca", # User.ReadWrite.All "bf7b1a76-6e77-406b-b258-bf5c7720e98f", # Group.Create "810c84a8-4a9e-49e6-bf7d-12d183f40d01", # Mail.Read "75359482-378d-4052-8f01-80520e7db3cd", # Files.ReadWrite.All "9492366f-7969-46a4-8d15-ed1a20078fff", # Sites.ReadWrite.All "0c0bf378-bf22-4481-8f81-9e89a9b4960a", # Sites.FullControl.All "b633e1c5-b582-4048-a93e-9f11b44c7e96", # Mail.Send "e2a3a72e-5f79-4c64-b1b1-878b674786c9", # Mail.ReadWrite "40f97065-369a-49f4-947c-6a255697ae91", # MailboxSettings.ReadWrite "ef54d2bf-783f-4e0f-bca1-3210c0444d99", # Calendars.ReadWrite "19dbc75e-c2e2-444c-a770-ec69d8559fc7", # Directory.ReadWrite.All "230c1aed-a721-4c5d-9cb4-a90514e508ef", # Reports.Read.All "06b708a9-e830-4db3-a914-8e69da51d44f", # AppRoleAssignment.ReadWrite.All "9e3f62cf-ca93-4989-b6ce-bf83c28f9fe8" # RoleManagement.ReadWrite.Directory ) foreach ($roleId in $graphAppRoles) { try { New-MgServicePrincipalAppRoleAssignment -ServicePrincipalId $graphServicePrincipal.Id -PrincipalId $servicePrincipal.Id -ResourceId $graphServicePrincipal.Id -AppRoleId $roleId -ErrorAction SilentlyContinue | Out-Null } catch { # Ignore already exists errors } } Write-Host "- Microsoft Graph permissions assigned" -ForegroundColor Green } catch { Write-Host "! Failed to assign Graph permissions: $($_.Exception.Message)" -ForegroundColor Yellow } # Assign Exchange Online permissions try { $exchangeServicePrincipal = Get-MgServicePrincipal -Filter "displayName eq 'Office 365 Exchange Online'" -ErrorAction Stop if ($exchangeServicePrincipal) { $exchangeAppRole = "dc50a0fb-09a3-484d-be87-e023b12c6440" # Exchange.ManageAsApp try { New-MgServicePrincipalAppRoleAssignment -ServicePrincipalId $exchangeServicePrincipal.Id -PrincipalId $servicePrincipal.Id -ResourceId $exchangeServicePrincipal.Id -AppRoleId $exchangeAppRole -ErrorAction SilentlyContinue | Out-Null Write-Host "- Exchange permissions assigned" -ForegroundColor Green } catch { # Ignore already exists errors } } } catch { Write-Host " ! Failed to assign Exchange permissions: $($_.Exception.Message)" -ForegroundColor Yellow } # Assign SharePoint permissions try { $sharePointServicePrincipal = Get-MgServicePrincipal -Filter "displayName eq 'Office 365 SharePoint Online'" -ErrorAction Stop if ($sharePointServicePrincipal) { $sharePointAppRoles = @( "678536fe-1083-478a-9c59-b99265e6b0d3", # Sites.FullControl.All "741f803b-c850-494e-b5df-cde7c675a1ca" # User.ReadWrite.All ) foreach ($roleId in $sharePointAppRoles) { try { New-MgServicePrincipalAppRoleAssignment -ServicePrincipalId $sharePointServicePrincipal.Id -PrincipalId $servicePrincipal.Id -ResourceId $sharePointServicePrincipal.Id -AppRoleId $roleId -ErrorAction SilentlyContinue | Out-Null } catch { # Ignore already exists errors } } Write-Host "- SharePoint permissions assigned" -ForegroundColor Green } } catch { Write-Host " ! Failed to assign SharePoint permissions: $($_.Exception.Message)" -ForegroundColor Yellow } # Assign Exchange Administrator role (required for Exchange Online App-Only Authentication) try { $exchangeAdminRoleId = "29232cdf-9323-42fd-ade2-1d097af3e4de" # Exchange Administrator $roleAssignmentBody = @{ principalId = $servicePrincipal.Id roleDefinitionId = $exchangeAdminRoleId directoryScopeId = "/" } Invoke-MgGraphRequest -Method POST -Uri "https://graph.microsoft.com/v1.0/roleManagement/directory/roleAssignments" -Body ($roleAssignmentBody | ConvertTo-Json) -ContentType "application/json" | Out-Null Write-Host "- Exchange Administrator role assigned" -ForegroundColor Green } catch { if ($_.Exception.Message -like "*already exists*" -or $_.Exception.Message -like "*already assigned*") { Write-Host "- Exchange Administrator role already assigned" -ForegroundColor Green } } # Try to assign Global Administrator role (optional but helpful) try { $globalAdminRoleId = "62e90394-69f5-4237-9190-012177145e10" # Global Administrator $roleAssignmentBody = @{ principalId = $servicePrincipal.Id roleDefinitionId = $globalAdminRoleId directoryScopeId = "/" } Invoke-MgGraphRequest -Method POST -Uri "https://graph.microsoft.com/v1.0/roleManagement/directory/roleAssignments" -Body ($roleAssignmentBody | ConvertTo-Json) -ContentType "application/json" | Out-Null Write-Host "- Global Administrator role assigned" -ForegroundColor Green } catch { # Optional role assignment, ignore failures } # Wait for role propagation Write-Host "Waiting for role propagation..." -ForegroundColor Yellow Start-Sleep -Seconds 30 Write-Host "- App created: $($app.AppId)" -ForegroundColor Green } else { Write-Host "- Using existing app: $($app.AppId)" -ForegroundColor Green # Check and add missing permissions to existing app try { $servicePrincipal = Get-MgServicePrincipal -Filter "appId eq '$($app.AppId)'" -ErrorAction Stop # Get service principals for permission checks $graphServicePrincipal = Get-MgServicePrincipal -Filter "displayName eq 'Microsoft Graph'" -ErrorAction Stop $exchangeServicePrincipal = Get-MgServicePrincipal -Filter "displayName eq 'Office 365 Exchange Online'" -ErrorAction SilentlyContinue $sharePointServicePrincipal = Get-MgServicePrincipal -Filter "displayName eq 'Office 365 SharePoint Online'" -ErrorAction SilentlyContinue # Check existing directory role assignments $existingRoleAssignments = Invoke-MgGraphRequest -Method GET -Uri "https://graph.microsoft.com/v1.0/roleManagement/directory/roleAssignments?`$filter=principalId eq '$($servicePrincipal.Id)'" -ErrorAction SilentlyContinue $exchangeAdminRoleId = "29232cdf-9323-42fd-ade2-1d097af3e4de" # Exchange Administrator $hasExchangeAdminRole = $existingRoleAssignments.value | Where-Object { $_.roleDefinitionId -eq $exchangeAdminRoleId } # Assign Exchange Administrator role if missing if (-not $hasExchangeAdminRole) { try { $roleAssignmentBody = @{ principalId = $servicePrincipal.Id roleDefinitionId = $exchangeAdminRoleId directoryScopeId = "/" } Invoke-MgGraphRequest -Method POST -Uri "https://graph.microsoft.com/v1.0/roleManagement/directory/roleAssignments" -Body ($roleAssignmentBody | ConvertTo-Json) -ContentType "application/json" -ErrorAction Stop | Out-Null Write-Host " - Added Exchange Administrator role" -ForegroundColor Green } catch { if ($_.Exception.Message -like "*already exists*" -or $_.Exception.Message -like "*already assigned*") { Write-Host " - Exchange Administrator role already assigned" -ForegroundColor Green } } } else { Write-Host " - Exchange Administrator role already assigned" -ForegroundColor Green } # Try to assign Global Administrator role if missing (optional but helpful) $globalAdminRoleId = "62e90394-69f5-4237-9190-012177145e10" # Global Administrator $hasGlobalAdminRole = $existingRoleAssignments.value | Where-Object { $_.roleDefinitionId -eq $globalAdminRoleId } if (-not $hasGlobalAdminRole) { try { $roleAssignmentBody = @{ principalId = $servicePrincipal.Id roleDefinitionId = $globalAdminRoleId directoryScopeId = "/" } Invoke-MgGraphRequest -Method POST -Uri "https://graph.microsoft.com/v1.0/roleManagement/directory/roleAssignments" -Body ($roleAssignmentBody | ConvertTo-Json) -ContentType "application/json" | Out-Null Write-Host " - Added Global Administrator role" -ForegroundColor Green } catch { # Ignore failures } } else { Write-Host " - Global Administrator role already assigned" -ForegroundColor Green } # Check Microsoft Graph permissions $existingGraphAssignments = Get-MgServicePrincipalAppRoleAssignment -ServicePrincipalId $servicePrincipal.Id | Where-Object { $_.ResourceId -eq $graphServicePrincipal.Id } $requiredGraphRoles = @( "62a82d76-70ea-41e2-9197-370581804d09", # Group.ReadWrite.All "741f803b-c850-494e-b5df-cde7c675a1ca", # User.ReadWrite.All "bf7b1a76-6e77-406b-b258-bf5c7720e98f", # Group.Create "810c84a8-4a9e-49e6-bf7d-12d183f40d01", # Mail.Read "75359482-378d-4052-8f01-80520e7db3cd", # Files.ReadWrite.All "9492366f-7969-46a4-8d15-ed1a20078fff", # Sites.ReadWrite.All "0c0bf378-bf22-4481-8f81-9e89a9b4960a", # Sites.FullControl.All (correct ID) "b633e1c5-b582-4048-a93e-9f11b44c7e96", # Mail.Send "e2a3a72e-5f79-4c64-b1b1-878b674786c9", # Mail.ReadWrite "40f97065-369a-49f4-947c-6a255697ae91", # MailboxSettings.ReadWrite "ef54d2bf-783f-4e0f-bca1-3210c0444d99" # Calendars.ReadWrite ) foreach ($roleId in $requiredGraphRoles) { $hasRole = $existingGraphAssignments | Where-Object { $_.AppRoleId -eq $roleId } if (-not $hasRole) { try { New-MgServicePrincipalAppRoleAssignment -ServicePrincipalId $graphServicePrincipal.Id -PrincipalId $servicePrincipal.Id -ResourceId $graphServicePrincipal.Id -AppRoleId $roleId -ErrorAction Stop | Out-Null } catch { # Ignore failures } } } # Check Exchange permissions $exchangeServicePrincipal = Get-MgServicePrincipal -Filter "displayName eq 'Office 365 Exchange Online'" -ErrorAction SilentlyContinue if ($exchangeServicePrincipal) { $existingExchangeAssignments = Get-MgServicePrincipalAppRoleAssignment -ServicePrincipalId $servicePrincipal.Id | Where-Object { $_.ResourceId -eq $exchangeServicePrincipal.Id } $exchangeAppRole = "dc50a0fb-09a3-484d-be87-e023b12c6440" # Exchange.ManageAsApp $hasExchangeRole = $existingExchangeAssignments | Where-Object { $_.AppRoleId -eq $exchangeAppRole } if (-not $hasExchangeRole) { try { New-MgServicePrincipalAppRoleAssignment -ServicePrincipalId $exchangeServicePrincipal.Id -PrincipalId $servicePrincipal.Id -ResourceId $exchangeServicePrincipal.Id -AppRoleId $exchangeAppRole -ErrorAction Stop | Out-Null } catch { # Ignore failures } } } # Check SharePoint permissions if ($sharePointServicePrincipal) { $existingSharePointAssignments = Get-MgServicePrincipalAppRoleAssignment -ServicePrincipalId $servicePrincipal.Id | Where-Object { $_.ResourceId -eq $sharePointServicePrincipal.Id } $requiredSharePointRoles = @( "678536fe-1083-478a-9c59-b99265e6b0d3", # Sites.FullControl.All "741f803b-c850-494e-b5df-cde7c675a1ca" # User.ReadWrite.All ) foreach ($roleId in $requiredSharePointRoles) { $hasRole = $existingSharePointAssignments | Where-Object { $_.AppRoleId -eq $roleId } if (-not $hasRole) { try { New-MgServicePrincipalAppRoleAssignment -ServicePrincipalId $sharePointServicePrincipal.Id -PrincipalId $servicePrincipal.Id -ResourceId $sharePointServicePrincipal.Id -AppRoleId $roleId -ErrorAction Stop | Out-Null } catch { # Ignore failures } } } } Write-Host "- Permissions configured" -ForegroundColor Green } catch { Write-Host " ! Could not check/update permissions: $($_.Exception.Message)" -ForegroundColor Yellow } } # Upload certificate to app with duplicate detection $fullApp = Get-MgApplication -ApplicationId $app.Id -Property "Id,KeyCredentials" -ErrorAction Stop $displayName = "PowerShell Certificate - $($cert.Thumbprint)" $existingKeys = @($fullApp.KeyCredentials) # Duplicate detection using multiple methods $certAlreadyRegistered = $false $certDerBase64 = [Convert]::ToBase64String($cert.RawData) # Compute thumbprint for comparison $hexThumb = ($cert.Thumbprint -replace ' ','') $thumbBytes = @() for ($i = 0; $i -lt $hexThumb.Length; $i += 2) { $thumbBytes += [Convert]::ToByte($hexThumb.Substring($i,2),16) } $thumbBase64 = [Convert]::ToBase64String($thumbBytes) foreach ($keyCred in $existingKeys) { $customIdBase64 = $null if ($keyCred.CustomKeyIdentifier) { $customIdBase64 = [Convert]::ToBase64String($keyCred.CustomKeyIdentifier) } $typeMatches = ($keyCred.Type -eq 'AsymmetricX509Cert') -or -not $keyCred.Type $keyMatches = $false if ($keyCred.Key) { try { $keyBase64 = [Convert]::ToBase64String($keyCred.Key) if ($keyBase64 -and ($keyBase64 -eq $certDerBase64)) { $keyMatches = $true } } catch {} } $displayMatches = ($keyCred.DisplayName -and ($keyCred.DisplayName -eq $displayName)) if ($typeMatches -and ( ($customIdBase64 -and ($customIdBase64 -eq $thumbBase64)) -or $keyMatches -or $displayMatches )) { if ($keyCred.EndDateTime -and $keyCred.EndDateTime -lt (Get-Date)) { Write-Host "! Certificate registration expired - will re-upload" -ForegroundColor Yellow } else { $certAlreadyRegistered = $true break } } } if (-not $certAlreadyRegistered) { Write-Host "Uploading certificate to app..." -ForegroundColor Yellow $newKey = @{ type = "AsymmetricX509Cert" usage = "Verify" key = $cert.RawData displayName = $displayName } $allKeys = @($existingKeys) + @($newKey) Update-MgApplication -ApplicationId $fullApp.Id -BodyParameter @{ keyCredentials = $allKeys } Write-Host "- Certificate uploaded, waiting for propagation..." -ForegroundColor Green Start-Sleep -Seconds 45 } else { Write-Host "- Certificate already registered" -ForegroundColor Green } # Step 4: Test certificate auth and get tenant info $context = Get-MgContext $actualTenantId = $context.TenantId # Get tenant domain information (only if not using stored tenant or tenant info is incomplete) if (-not $useStoredTenant -or -not $selectedTenantInfo.PrimaryDomain) { $domains = Get-MgDomain -All -ErrorAction SilentlyContinue $initialDomain = ($domains | Where-Object { $_.IsInitial }).Id $primaryDomain = ($domains | Where-Object { $_.IsDefault -eq $true }).Id if (-not $primaryDomain) { $primaryDomain = ($domains | Where-Object { $_.Id -notlike "*.onmicrosoft.com" }).Id | Select-Object -First 1 } if (-not $primaryDomain) { $primaryDomain = $initialDomain } # Find SharePoint tenant name using Microsoft Graph $sharePointTenantName = $initialDomain.Split(".")[0] # Default fallback try { Write-Host "- Querying SharePoint root site..." -ForegroundColor Yellow $rootSite = Get-MgSite -SiteId "root" -ErrorAction Stop if ($rootSite -and $rootSite.WebUrl) { Write-Host "- Found SharePoint root site: $($rootSite.WebUrl)" -ForegroundColor Green # Extract tenant name from URL like https://theoutdoorsgroupltd.sharepoint.com if ($rootSite.WebUrl -match "https://([^.]+)\.sharepoint\.com") { $sharePointTenantName = $matches[1] Write-Host "- Extracted SharePoint tenant: $sharePointTenantName" -ForegroundColor Green } } } catch { Write-Host " ! Could not query SharePoint root site, using default: $sharePointTenantName" -ForegroundColor Yellow } $tenantName = $initialDomain.Split(".")[0] } else { # Use stored tenant information $primaryDomain = $selectedTenantInfo.PrimaryDomain $initialDomain = $selectedTenantInfo.InitialDomain $tenantName = $selectedTenantInfo.TenantName $sharePointTenantName = $selectedTenantInfo.SharePointTenantName } # Switch to certificate authentication if needed $currentContext = Get-MgContext $needsCertificateReconnection = $false if (-not $currentContext) { $needsCertificateReconnection = $true } elseif ($currentContext.TenantId -ne $actualTenantId) { $needsCertificateReconnection = $true } elseif ($currentContext.AuthType -eq "Delegated" -and $currentContext.TenantId -eq $actualTenantId) { $needsCertificateReconnection = $false } elseif ($currentContext.AuthType -eq "AppOnly" -and $currentContext.ClientId -ne $app.AppId) { $needsCertificateReconnection = $true } elseif ($currentContext.AuthType -eq "AppOnly" -and $currentContext.TenantId -ne $actualTenantId) { $needsCertificateReconnection = $true } if ($needsCertificateReconnection) { Connect-MgGraph -ClientId $app.AppId -TenantId $actualTenantId -CertificateThumbprint $cert.Thumbprint -NoWelcome -ContextScope Process -ErrorAction Stop | Out-Null 2>&1 } # Quick API test $null = Get-MgUser -Top 1 -ErrorAction Stop # Save tenant information for future use (if not using stored tenant or if tenant info was updated) if (-not $useStoredTenant -or -not $selectedTenantInfo.PrimaryDomain) { $tenantInfoToSave = @{ TenantId = $actualTenantId TenantName = $tenantName PrimaryDomain = $primaryDomain InitialDomain = $initialDomain SharePointTenantName = $sharePointTenantName AppId = $app.AppId AppName = $AppName CertSubject = $CertSubject Thumbprint = $cert.Thumbprint } Save-TenantInfo -TenantInfo $tenantInfoToSave } elseif ($useStoredTenant) { # Update last used timestamp for stored tenant $tenantInfoToSave = @{ TenantId = $selectedTenantInfo.TenantId TenantName = $selectedTenantInfo.TenantName PrimaryDomain = $selectedTenantInfo.PrimaryDomain InitialDomain = $selectedTenantInfo.InitialDomain SharePointTenantName = $selectedTenantInfo.SharePointTenantName AppId = $selectedTenantInfo.AppId AppName = $selectedTenantInfo.AppName CertSubject = $selectedTenantInfo.CertSubject Thumbprint = $selectedTenantInfo.Thumbprint } Save-TenantInfo -TenantInfo $tenantInfoToSave } # Return certificate and connection info # Export certificate with private key as base64 for PnP PowerShell $tempPassword = ConvertTo-SecureString -String "TempPassword123!" -Force -AsPlainText $tempPfxPath = [System.IO.Path]::Combine([System.IO.Path]::GetTempPath(), "temp_cert_$($cert.Thumbprint).pfx") try { # Cross-platform certificate export if ($PSVersionTable.PSVersion.Major -ge 6) { # PowerShell 6+ (Core/7) - use .NET method $pfxBytes = $cert.Export([System.Security.Cryptography.X509Certificates.X509ContentType]::Pkcs12, $tempPassword) $certBase64 = [Convert]::ToBase64String($pfxBytes) } else { # Windows PowerShell 5.1 - use cmdlet Export-PfxCertificate -Cert $cert -FilePath $tempPfxPath -Password $tempPassword -Force | Out-Null $pfxBytes = [System.IO.File]::ReadAllBytes($tempPfxPath) $certBase64 = [Convert]::ToBase64String($pfxBytes) Remove-Item $tempPfxPath -Force -ErrorAction SilentlyContinue } } catch { $certBase64 = [Convert]::ToBase64String($cert.RawData) $tempPassword = $null } return @{ Certificate = $cert AppId = $app.AppId TenantId = $actualTenantId Thumbprint = $cert.Thumbprint CertificateBase64 = $certBase64 CertificatePassword = $tempPassword TenantName = $tenantName SharePointTenantName = $sharePointTenantName PrimaryDomain = $primaryDomain InitialDomain = $initialDomain } } catch { throw "Certificate setup failed: $($_.Exception.Message)" } } function Show-CertificateAuthHelp { <# .SYNOPSIS Shows help and examples for using certificate authentication with Microsoft 365 services .DESCRIPTION Displays connection examples for all major Microsoft 365 PowerShell modules using certificate authentication #> Write-Host "`nCertificate Authentication Help" -ForegroundColor Cyan Write-Host "===============================" -ForegroundColor Cyan Write-Host "Microsoft 365 Certificate Authentication Module`n" -ForegroundColor Gray Write-Host "BASIC USAGE:" -ForegroundColor Yellow Write-Host "`$auth = Get-CertificateForAuth" -ForegroundColor White Write-Host "Returns object with all connection details:" -ForegroundColor Gray Write-Host " - AppId, TenantId, Thumbprint (for certificate-based connections)" -ForegroundColor Gray Write-Host " - CertificateBase64, CertificatePassword (for PnP PowerShell)" -ForegroundColor Gray Write-Host " - TenantName, PrimaryDomain, InitialDomain (tenant information)" -ForegroundColor Gray Write-Host " - SharePointTenantName (for SharePoint URLs)" -ForegroundColor Gray Write-Host "Note: First run requires interactive login; subsequent runs show tenant selection menu" -ForegroundColor Gray Write-Host " Module automatically connects to Microsoft Graph with required permissions`n" -ForegroundColor Gray Write-Host "CONNECTION EXAMPLES:" -ForegroundColor Yellow Write-Host "The module provides all necessary values - use them directly:" -ForegroundColor Gray Write-Host "To see all available values: `$auth | Format-List" -ForegroundColor Gray Write-Host "`nExchange Online:" -ForegroundColor Cyan Write-Host "Connect-ExchangeOnline -AppId `$auth.AppId -CertificateThumbprint `$auth.Thumbprint -Organization `$auth.InitialDomain" -ForegroundColor White Write-Host "`nMicrosoft Graph:" -ForegroundColor Cyan Write-Host "Connect-MgGraph -ClientId `$auth.AppId -TenantId `$auth.TenantId -CertificateThumbprint `$auth.Thumbprint" -ForegroundColor White Write-Host "`nSharePoint Online (PnP):" -ForegroundColor Cyan Write-Host "Connect-PnPOnline -Url `"https://`$(`$auth.SharePointTenantName).sharepoint.com`" -ClientId `$auth.AppId -Tenant `$auth.InitialDomain -CertificateBase64Encoded `$auth.CertificateBase64 -CertificatePassword `$auth.CertificatePassword" -ForegroundColor White Write-Host "`nMicrosoft Teams:" -ForegroundColor Cyan Write-Host "Connect-MicrosoftTeams -ApplicationId `$auth.AppId -TenantId `$auth.TenantId -CertificateThumbprint `$auth.Thumbprint" -ForegroundColor White Write-Host "`nAzure PowerShell:" -ForegroundColor Cyan Write-Host "Connect-AzAccount -ApplicationId `$auth.AppId -TenantId `$auth.TenantId -CertificateThumbprint `$auth.Thumbprint -ServicePrincipal" -ForegroundColor White Write-Host "`nSecurity & Compliance:" -ForegroundColor Cyan Write-Host "Connect-IPPSSession -AppId `$auth.AppId -CertificateThumbprint `$auth.Thumbprint -Organization `$auth.InitialDomain" -ForegroundColor White Write-Host "`nPERMISSIONS INCLUDED:" -ForegroundColor Yellow Write-Host "- Microsoft Graph: User, Group, Mail, Files, Sites, Directory, Reports" -ForegroundColor White Write-Host "- Exchange Online: ManageAsApp (full mailbox access)" -ForegroundColor White Write-Host "- SharePoint: FullControl and User permissions" -ForegroundColor White Write-Host "- Exchange Administrator role (required for Exchange Online App-Only auth)" -ForegroundColor White Write-Host "- Global Administrator role (optional, enhances compatibility)" -ForegroundColor White Write-Host "`nTENANT MANAGEMENT:" -ForegroundColor Yellow Write-Host "- Automatically stores tenant information after first login" -ForegroundColor White Write-Host "- Prompts for tenant selection on subsequent runs" -ForegroundColor White Write-Host "- Encrypted local storage for security (no enforced tenant limit)" -ForegroundColor White Write-Host "- Automatic certificate validation and expiry checking" -ForegroundColor White Write-Host "- Automatic cleanup of invalid/expired tenant entries" -ForegroundColor White Write-Host "`nTenant Management Commands:" -ForegroundColor Yellow Write-Host "Show-StoredTenants # Display formatted list of stored tenants with details" -ForegroundColor White Write-Host "Get-StoredTenants # Get stored tenants as objects for scripting" -ForegroundColor White Write-Host "Remove-StoredTenant # Interactive removal of specific stored tenant" -ForegroundColor White Write-Host "Clear-StoredTenants # Clear all stored tenants (with confirmation)" -ForegroundColor White Write-Host "`nTenant Management Workflow:" -ForegroundColor Yellow Write-Host "1. First run: `$auth = Get-CertificateForAuth # Two logins required for new tenants" -ForegroundColor White Write-Host "2. Next runs: `$auth = Get-CertificateForAuth # Shows menu, select saved tenant" -ForegroundColor White Write-Host "3. Manage: Show-StoredTenants # View all saved tenants" -ForegroundColor White Write-Host "4. Cleanup: Remove-StoredTenant # Remove specific tenants" -ForegroundColor White Write-Host "`nAdvanced Usage:" -ForegroundColor Yellow Write-Host "`$auth = Get-CertificateForAuth -TenantId 'specific-tenant-id' # Bypass selection" -ForegroundColor White Write-Host "`$auth = Get-CertificateForAuth -UseTenantSelection `$false # Disable tenant selection" -ForegroundColor White Write-Host "`nREQUIREMENTS:" -ForegroundColor Yellow Write-Host "- Microsoft.Graph.Authentication module (for Graph connections)" -ForegroundColor White Write-Host "- Microsoft.Graph.Applications module (for app registration management)" -ForegroundColor White Write-Host "- Microsoft.Graph.Identity.DirectoryManagement module (for permissions)" -ForegroundColor White Write-Host "- PowerShell 5.1 or higher" -ForegroundColor White Write-Host "- Windows certificate store access" -ForegroundColor White Write-Host "`nCERTIFICATE MANAGEMENT:" -ForegroundColor Yellow Write-Host "- Automatically creates certificates if none exist" -ForegroundColor White Write-Host "- Validates expiry dates and private key access" -ForegroundColor White Write-Host "- Cleans up expired certificates automatically" -ForegroundColor White Write-Host "- Uploads certificates to app registration" -ForegroundColor White Write-Host "- Handles duplicate detection and re-registration" -ForegroundColor White } Export-ModuleMember -Function Get-CertificateForAuth, Show-CertificateAuthHelp, Get-StoredTenants, Show-StoredTenants, Remove-StoredTenant, Clear-StoredTenants |