Private/Auth/Connect-GraphSession.ps1
|
# Copyright (c) 2026 Sandy Zeng. All rights reserved. # Source-available. All rights reserved. See LICENSE file. <# Connect-GraphSession.ps1 — MSAL-based authentication: sign-in, silent token refresh, and account management. Author: Sandy Zeng Project: IntuneDiff Version History: 1.0.0 Initial release. 1.0.1 Switched from WAM broker to system browser for reliability. 1.0.2 Token cache is in-memory only; no tokens written to disk. #> if (-not $script:MSALApp) { $script:MSALApp = $null } $script:GraphClientId = '14d82eec-204b-4c2f-b7e8-296a70dab67e' # Microsoft Graph Command Line Tools # Remove any legacy token cache file left by earlier versions of this module $_legacyCachePath = Join-Path $env:LOCALAPPDATA 'IntuneDiff\msalcache.bin' if (Test-Path -LiteralPath $_legacyCachePath) { Remove-Item -LiteralPath $_legacyCachePath -Force -ErrorAction SilentlyContinue } Remove-Variable _legacyCachePath -ErrorAction SilentlyContinue function Get-IntuneDiffRequiredScopes { <# .SYNOPSIS Returns the list of Microsoft Graph permission scopes required by IntuneDiff. #> [CmdletBinding()] [OutputType([string[]])] param() return @( 'DeviceManagementConfiguration.Read.All' ) } function Get-IntuneDiffMissingScopes { <# .SYNOPSIS Returns any required Graph scopes absent from the current signed-in context. ReadWrite variants satisfy a Read requirement. #> [CmdletBinding()] [OutputType([string[]])] param([string[]]$Required = (Get-IntuneDiffRequiredScopes)) if (-not (Test-GraphConnection)) { return $Required } $have = @((Get-MgContext).Scopes) $missing = [System.Collections.Generic.List[string]]::new() foreach ($scope in $Required) { if ($have -contains $scope) { continue } $parts = $scope.Split('.') if ($parts.Length -ge 2 -and $parts[1] -eq 'Read') { $rwParts = $parts.Clone() $rwParts[1] = 'ReadWrite' if ($have -contains ($rwParts -join '.')) { continue } } $missing.Add($scope) | Out-Null } return @($missing) } function Get-TenantDisplayInfo { <# .SYNOPSIS Returns the tenant display name, ID, and default domain from Microsoft Graph. #> [CmdletBinding()] param() if (-not (Test-GraphConnection)) { return $null } try { $resp = Invoke-MgGraphRequest -Method GET -Uri 'https://graph.microsoft.com/v1.0/organization?$select=id,displayName,verifiedDomains' -ErrorAction Stop $org = $resp.value | Select-Object -First 1 if (-not $org) { return $null } $defaultDomain = ($org.verifiedDomains | Where-Object { $_.isDefault } | Select-Object -First 1).name [pscustomobject]@{ TenantId = $org.id DisplayName = $org.displayName DefaultDomain = $defaultDomain } } catch { $null } } function Initialize-MSALApp { <# .SYNOPSIS Initializes the MSAL PublicClientApplication with in-memory token cache only. No tokens are written to disk — users sign in fresh each session. #> [CmdletBinding()] param() if ($script:MSALApp) { return $script:MSALApp } # Ensure Microsoft.Graph.Authentication is loaded and load MSAL DLL try { Import-Module Microsoft.Graph.Authentication -ErrorAction Stop } catch { throw "Microsoft.Graph.Authentication module is not available. Install it with: Install-Module Microsoft.Graph.Authentication -Scope CurrentUser`n$($_.Exception.Message)" } # Load MSAL assembly explicitly from the Graph module dependencies if (-not ([System.Management.Automation.PSTypeName]'Microsoft.Identity.Client.PublicClientApplicationBuilder').Type) { $mgMod = Get-Module Microsoft.Graph.Authentication $msalDll = Get-ChildItem -Path $mgMod.ModuleBase -Filter 'Microsoft.Identity.Client.dll' -Recurse | Select-Object -First 1 if (-not $msalDll) { throw 'Microsoft.Identity.Client.dll not found in Microsoft.Graph.Authentication module.' } Add-Type -Path $msalDll.FullName -ErrorAction Stop } # Build PCA with system browser (no WAM broker) Write-IDLog 'Initializing MSAL PublicClientApplication (browser)...' $builder = [Microsoft.Identity.Client.PublicClientApplicationBuilder]::Create($script:GraphClientId) $builder = $builder.WithAuthority('https://login.microsoftonline.com/organizations/') $builder = $builder.WithRedirectUri('http://localhost') $script:MSALApp = $builder.Build() # Token cache is in-memory only — nothing is written to disk return $script:MSALApp } function Wait-TaskWithDispatcher { <# .SYNOPSIS Waits for an async Task while keeping the WPF dispatcher responsive. #> param([System.Threading.Tasks.Task]$Task) $dispatcher = [System.Windows.Threading.Dispatcher]::CurrentDispatcher while (-not $Task.IsCompleted) { # Process pending WPF messages to keep UI alive $dispatcher.Invoke([System.Action]{ }, [System.Windows.Threading.DispatcherPriority]::Background) [System.Threading.Thread]::Sleep(50) } if ($Task.IsFaulted) { throw $Task.Exception.InnerException } return $Task.Result } function Get-MSALCachedAccounts { <# .SYNOPSIS Returns the list of cached MSAL accounts. #> [CmdletBinding()] param() $app = Initialize-MSALApp $accounts = $app.GetAccountsAsync().GetAwaiter().GetResult() return @($accounts) } function Connect-GraphSession { <# .SYNOPSIS Signs in to Microsoft Graph. Supports silent switching via cached MSAL accounts. .PARAMETER Account An IAccount object from Get-MSALCachedAccounts to sign in silently. .PARAMETER TenantId Optional tenant ID or domain. .PARAMETER Scopes Scopes to request. If omitted, uses required scopes. #> [CmdletBinding()] param( [object]$Account, [string]$TenantId, [string[]]$Scopes ) $app = Initialize-MSALApp if (-not $Scopes -or $Scopes.Count -eq 0) { # Use .default to get all admin-consented permissions without triggering consent prompt $Scopes = @('https://graph.microsoft.com/.default') } Write-IDLog "Connect-GraphSession: Account=$($Account.Username), Scopes=$($Scopes -join ', ')" $authResult = $null # Try silent authentication with a cached account if ($Account) { Write-IDLog "Attempting silent token acquisition for $($Account.Username)..." try { $silentBuilder = $app.AcquireTokenSilent($Scopes, $Account) if ($TenantId) { $silentBuilder = $silentBuilder.WithAuthority("https://login.microsoftonline.com/$TenantId/") } $authResult = Wait-TaskWithDispatcher $silentBuilder.ExecuteAsync() Write-IDLog 'Silent token acquired successfully' } catch { Write-IDLog "Silent auth failed: $($_.Exception.Message) — falling through to interactive" $authResult = $null } } # Interactive authentication if (-not $authResult) { Write-IDLog 'Starting interactive auth (system browser)...' $interactiveBuilder = $app.AcquireTokenInteractive($Scopes) if ($TenantId) { $interactiveBuilder = $interactiveBuilder.WithAuthority("https://login.microsoftonline.com/$TenantId/") } if ($Account) { $interactiveBuilder = $interactiveBuilder.WithAccount($Account) } else { # Force account picker when no specific account is requested $interactiveBuilder = $interactiveBuilder.WithPrompt([Microsoft.Identity.Client.Prompt]::SelectAccount) } # Always use system browser — no embedded WebView, no WAM broker $interactiveBuilder = $interactiveBuilder.WithUseEmbeddedWebView($false) # Keep HTML minimal (no embedded images) so it renders reliably on fast SSO redirects $webViewOptions = [Microsoft.Identity.Client.SystemWebViewOptions]::new() $webViewOptions.HtmlMessageSuccess = @' <!DOCTYPE html><html><head><meta charset="utf-8"> <title>IntuneDiff — Signed In</title> <style> * { margin: 0; padding: 0; box-sizing: border-box; } body { background: #111827; color: #F3F4F6; font-family: -apple-system, Segoe UI, sans-serif; display: flex; align-items: center; justify-content: center; height: 100vh; } .card { background: #1F2937; border: 1px solid #374151; border-radius: 16px; padding: 48px 56px; text-align: center; max-width: 480px; } .icon { font-size: 52px; margin-bottom: 20px; } h1 { font-size: 22px; font-weight: 700; margin-bottom: 10px; color: #F9FAFB; } p { font-size: 14px; color: #9CA3AF; line-height: 1.6; } </style></head> <body><div class="card"> <div class="icon">✅</div> <h1>Authentication complete</h1> <p>You are signed in. You can close this tab and return to IntuneDiff.</p> </div></body></html> '@ $webViewOptions.HtmlMessageError = @' <!DOCTYPE html><html><head><meta charset="utf-8"> <title>IntuneDiff — Sign-in Failed</title> <style> * { margin: 0; padding: 0; box-sizing: border-box; } body { background: #111827; color: #F3F4F6; font-family: -apple-system, Segoe UI, sans-serif; display: flex; align-items: center; justify-content: center; height: 100vh; } .card { background: #1F2937; border: 1px solid #374151; border-radius: 16px; padding: 48px 56px; text-align: center; max-width: 480px; } .icon { font-size: 52px; margin-bottom: 20px; } h1 { font-size: 22px; font-weight: 700; margin-bottom: 10px; color: #F9FAFB; } p { font-size: 14px; color: #9CA3AF; line-height: 1.6; } .error { color: #FCA5A5; font-size: 13px; margin-top: 12px; } </style></head> <body><div class="card"> <div class="icon">❌</div> <h1>Sign-in failed</h1> <p>Something went wrong. Please close this tab and try again in IntuneDiff.</p> <p class="error">{0}</p> </div></body></html> '@ $interactiveBuilder = $interactiveBuilder.WithSystemWebViewOptions($webViewOptions) $authResult = Wait-TaskWithDispatcher $interactiveBuilder.ExecuteAsync() } if (-not $authResult -or [string]::IsNullOrEmpty($authResult.AccessToken)) { throw 'Sign-in did not complete. No token was acquired.' } Write-IDLog "Token acquired for $($authResult.Account.Username) (Tenant: $($authResult.TenantId))" # Connect Microsoft.Graph SDK using the acquired token $secureToken = ConvertTo-SecureString $authResult.AccessToken -AsPlainText -Force # Reset stale Graph SDK state that persists across module reloads try { Disconnect-MgGraph -ErrorAction SilentlyContinue | Out-Null } catch {} # Force-reimport to reset the SDK's internal singleton Import-Module Microsoft.Graph.Authentication -Force -ErrorAction SilentlyContinue try { Connect-MgGraph -AccessToken $secureToken -NoWelcome | Out-Null } catch { try { Connect-MgGraph -AccessToken $secureToken | Out-Null } catch { throw "Failed to connect to Microsoft Graph SDK: $($_.Exception.Message)" } } Write-IDLog 'Connected to Microsoft Graph' $script:SignedInUser = [pscustomobject]@{ Account = $authResult.Account.Username TenantId = $authResult.TenantId Scopes = $authResult.Scopes } return $script:SignedInUser } function Remove-MSALCachedAccount { <# .SYNOPSIS Removes a specific account from the MSAL token cache. #> [CmdletBinding()] param([object]$Account) if ($Account -and $script:MSALApp) { $script:MSALApp.RemoveAsync($Account).GetAwaiter().GetResult() | Out-Null } } |