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">&#x2705;</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">&#x274C;</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
    }
}