Files
IntuneManagement/UI/Avalonia/Extensions/MSGraphAuthenticationUIAvalonia.ps1

376 lines
16 KiB
PowerShell

# UI/Avalonia counterpart of UI/WPF/Extensions/MSGraphAuthenticationUI.ps1.
#
# Scope: event handlers + dialog ports that depend on Avalonia controls
# (Show-MSALDecodedToken, Show-CloudPickerMenu, the four AuthenticationXxx event
# handlers, Invoke-MSALUIAppInitialized). The non-UI auth helpers
# (Invoke-AuthProviderInteractiveLogin, Get-MSALUserInfo) live in
# Internal/MSGraphAuthentication.ps1 and are shared by both UI trees.
#
# Deliberately NOT ported here (yet):
# - Get-MSALUserProfile — replaced by an inline Avalonia helper inside
# Show-AuthenticationInfo for the simplified initials button. The full
# cached-account picker / popup version lives in WPF only for now.
# - Get-MSALUserPhotoEllips — deferred until the popup infrastructure
# (Show-Popup) is ported.
Add-AppEventHandler "AppInitialized" "Invoke-MSALUIAppInitialized"
function Show-MSALDecodedToken
{
<#
.SYNOPSIS
Render a decoded JWT (header + payload) in a modal DataGrid.
.DESCRIPTION
Port of UI/WPF/Extensions/MSGraphAuthenticationUI.ps1 Show-MSALDecodedToken.
Same formatting rules: exp/iat/nbf/xms_tcdt -> local DateTime,
acrs/amr/scp -> newline-joined, wids -> resolved Entra role displayName
when available. Output is a list of [TokenInfoRow] CLR instances bound
into a DataGrid with auto-generated columns.
#>
param($TokenData, $Title)
$ui = $script:UIProvider
if (-not $TokenData -or -not $TokenData.Header) { return }
$tokenArr = @()
foreach ($prop in ($TokenData.Header | Get-Member | Where-Object MemberType -eq NoteProperty)) {
$tokenArr += [TokenInfoRow]@{
Name = $prop.Name
Value = $TokenData.Header."$($prop.Name)"
}
}
# Each claim yields one or more GRID ROWS rather than one row holding a
# multi-line string. Newline-joining a long list put a single row several
# times taller than the rest (8 permissions measured 147px against 33px),
# and the DataGrid estimates its scroll extent from the row heights it has
# realized - so that one outlier made the extent swing as it scrolled in and
# out of view, giving a jumpy, flickering scrollbar and a mouse wheel that
# appeared to run through the table several times. One value per row keeps
# every row a uniform height and stays fully readable.
foreach ($prop in ($TokenData.Payload | Get-Member | Where-Object MemberType -eq NoteProperty)) {
$raw = $TokenData.Payload."$($prop.Name)"
if ($prop.Name -in @('exp','iat','nbf','xms_tcdt')) {
$values = @([datetime]::new(1970, 1, 1, 0, 0, 0, 0, [System.DateTimeKind]::Utc).AddSeconds($raw).ToLocalTime())
}
elseif ($prop.Name -in @('acrs','amr')) {
# Short arrays; a single ';'-joined line stays well inside one row.
$values = @($raw -join ';')
}
elseif ($prop.Name -in @('wids')) {
if (-not $script:AADRoles) {
# Will fail if RoleManagement.Read.Directory permission isn't
# granted. -NoError swallows the failure; we just render the
# raw GUIDs without a friendly name.
$script:AADRoles = (Invoke-MSGraphAPI -url "/directoryRoles?`$select=roleTemplateId,displayName" -ODataMetadata 'minimal' -Noerror).value
}
$values = @()
foreach ($wid in $raw) {
$text = $wid
$role = ($script:AADRoles | Where-Object roleTemplateId -eq $wid)
if ($role) { $text = "$text ($($role.displayName))" }
$values += $text
}
}
elseif ($prop.Name -eq 'scp') {
# Delegated tokens pack the scopes into one space-separated string.
$values = @(([string]$raw).Split(' ') | Where-Object { $_ })
}
else {
# Covers 'roles' (an array on app-only tokens) and any other array
# claim: one row each, so nothing ever renders as "System.Object[]".
$values = @($raw)
}
foreach ($v in $values) {
$tokenArr += [TokenInfoRow]@{
Name = $prop.Name
Value = $v
}
}
}
$dg = [Avalonia.Controls.DataGrid]::new()
$dg.AutoGenerateColumns = $true
$dg.IsReadOnly = $true
$dg.CanUserSortColumns = $true
$dg.CanUserResizeColumns = $true
$dg.GridLinesVisibility = [Avalonia.Controls.DataGridGridLinesVisibility]::Horizontal
$dg.MinWidth = 600
$dg.MinHeight = 400
$dg.ItemsSource = $tokenArr
$ui.ShowModalForm($Title, $dg)
}
function Show-CloudPickerMenu
{
<#
.SYNOPSIS
Modal dialog to choose a target cloud before sign-in.
.DESCRIPTION
Avalonia port of UI/WPF/Extensions/MSGraphAuthenticationUI.ps1
Show-CloudPickerMenu. Returns the selected Cloud value (string) on OK,
$null on Cancel. -Persist saves the choice to DefaultCloud for future
zero-config sign-ins.
.NOTES
$script:Clouds items are PSCustomObjects (Name / Value); converted to
[SettingsListItem] CLR instances so Avalonia ComboBox bindings resolve.
#>
param(
[string]$Default,
[switch]$Persist
)
$ui = $script:UIProvider
$dialog = $ui.GetXamlObject((Join-Path $script:AppUIRootFolder 'XAML/CloudPickerMenu.axaml'))
if (-not $dialog) { return $null }
$cbCloud = (Get-AvaloniaHost)::FindByName($dialog, 'cbCloud')
if (-not $cbCloud) { return $null }
$items = @()
foreach ($cloud in @($script:Clouds)) {
if (-not $cloud) { continue }
$items += [SettingsListItem]@{
Name = [string]$cloud.Name
Value = $cloud.Value
}
}
$cbCloud.ItemsSource = $items
if (-not $Default) { $Default = Get-DefaultCloud }
$selected = $items | Where-Object { "$($_.Value)" -eq "$Default" } | Select-Object -First 1
if (-not $selected -and $items.Count -gt 0) { $selected = $items[0] }
if ($selected) { $cbCloud.SelectedItem = $selected }
# Module scope, not captures: the handlers are re-bound and lose locals, so
# Sign in / Cancel never closed the dialog and it always returned $null -
# "Sign in to a different cloud..." could never actually sign in. Reset on
# entry so a previous open cannot leak its answer into this one.
$script:_cloudPickerDialog = $dialog
$script:_cloudPickerCombo = $cbCloud
$script:_cloudPickerResult = $null
$ui.AddXamlEvent($dialog, 'btnLogin', 'Add_Click', ({
if ($script:_cloudPickerCombo -and $script:_cloudPickerCombo.SelectedItem) {
$script:_cloudPickerResult = [string]$script:_cloudPickerCombo.SelectedItem.Value
}
if ($script:_cloudPickerDialog) { $script:_cloudPickerDialog.Close() }
}))
$ui.AddXamlEvent($dialog, 'btnCancel', 'Add_Click', ({
$script:_cloudPickerResult = $null
if ($script:_cloudPickerDialog) { $script:_cloudPickerDialog.Close() }
}))
(Get-AvaloniaHost)::ShowDialog($dialog, $script:Window)
$picked = $script:_cloudPickerResult
$script:_cloudPickerDialog = $null
$script:_cloudPickerCombo = $null
$script:_cloudPickerResult = $null
if (-not $picked) { return $null }
if ($Persist) { Set-DefaultCloud $picked }
return [string]$picked
}
# Filters out Microsoft personal / consumer (MSA) accounts. Those accounts live
# on the well-known tenant 9188040d-6c67-4c5b-b112-36a304b66dad and cannot be
# used to sign into Entra-tenant resources (Intune / Graph) — surfacing them in
# the picker only confuses the user. Keep work/school accounts plus accounts
# where tenant info isn't available. (Verbatim from WPF MSGraphAuthenticationUI.)
function Test-IsPersonalMSAAccount {
param($Account)
if(-not $Account -or -not $Account.HomeAccountId) { return $false }
$tid = $Account.HomeAccountId.TenantId
return ($tid -eq "9188040d-6c67-4c5b-b112-36a304b66dad")
}
function Invoke-AvaloniaCachedAccountSignIn {
param($Account)
if(-not $Account) { return }
Write-Status "Logging in with $($Account.UserName)"
try {
if (Connect-EntraEnvironment -User $Account.UserName -DefaultToken) {
# AuthenticatedNewToken event handler refreshes the UI.
}
}
catch {
Write-LogError "Cached-account sign-in failed" $_.Exception
}
finally {
Write-Status ""
}
}
# Build one row in the cached-account picker — Avalonia version of WPF
# Add-CachedUser. Each row is a 2-column Grid: a click-to-login button on the
# left that calls Connect-EntraEnvironment with the cached username, and a
# Forget button on the right that disconnects (if signed in under this account)
# and evicts the MSAL cache entry.
function Add-CachedUser {
param($Account, $ParentObj)
$ui = $script:UIProvider
try {
$row = [Avalonia.Controls.Grid]::new()
$row.Margin = [Avalonia.Thickness]::new(0, 5, 0, 0)
$row.ColumnDefinitions.Add([Avalonia.Controls.ColumnDefinition]::new([Avalonia.Controls.GridLength]::new(1, [Avalonia.Controls.GridUnitType]::Star)))
$row.ColumnDefinitions.Add([Avalonia.Controls.ColumnDefinition]::new([Avalonia.Controls.GridLength]::Auto))
$tenantName = Get-SettingStoreValue $Account.HomeAccountId.TenantId "_Name" $Account.HomeAccountId.TenantId
$loginBtn = [Avalonia.Controls.Button]::new()
$loginBtn.HorizontalAlignment = [Avalonia.Layout.HorizontalAlignment]::Stretch
$loginBtn.HorizontalContentAlignment = [Avalonia.Layout.HorizontalAlignment]::Left
$loginBtn.Cursor = [Avalonia.Input.Cursor]::new([Avalonia.Input.StandardCursorType]::Hand)
$loginBtn.Tag = $Account
# TextBlock.Inlines + Run/LineBreak to render the two-line username/tenant
# label without needing a separate StackPanel host.
$loginText = [Avalonia.Controls.TextBlock]::new()
$loginText.Inlines.Add([Avalonia.Controls.Documents.Run]::new($Account.UserName))
$loginText.Inlines.Add([Avalonia.Controls.Documents.LineBreak]::new())
$loginText.Inlines.Add([Avalonia.Controls.Documents.Run]::new([string]$tenantName))
$loginBtn.Content = $loginText
$loginBtn.add_Click((ConvertTo-AvaloniaEventScriptBlock {
param($S, $E)
$acct = $S.Tag
$ui.HidePopup()
Invoke-AvaloniaDeferredAction -Argument $acct -Action {
param($selectedAccount)
Invoke-AvaloniaCachedAccountSignIn $selectedAccount
}
}))
$row.Children.Add($loginBtn) | Out-Null
$forgetBtn = [Avalonia.Controls.Button]::new()
# U+1F5D1 (🗑) is outside the BMP and won't fit in a single 16-bit
# [char]. ConvertFromUtf32 returns the proper surrogate-pair string.
$forgetBtn.Content = [char]::ConvertFromUtf32(0x1F5D1)
$forgetBtn.Margin = [Avalonia.Thickness]::new(5, 0, 0, 0)
[Avalonia.Controls.ToolTip]::SetTip($forgetBtn, 'Forget this account')
$forgetBtn.Cursor = [Avalonia.Input.Cursor]::new([Avalonia.Input.StandardCursorType]::Hand)
$forgetBtn.Tag = $Account
[Avalonia.Controls.Grid]::SetColumn($forgetBtn, 1)
$forgetBtn.add_Click((ConvertTo-AvaloniaEventScriptBlock {
param($S, $E)
$acct = $S.Tag
Write-Status "Removing $($acct.UserName)"
try {
# If the account is currently signed in under any token, disconnect
# properly first so the event fires and any default-token promotion
# runs. Always also evict from the on-disk MSAL cache.
$matchingTokens = @($script:MSALTokens.Values | Where-Object {
$_.Token -and $_.Token.Account -and
$_.Token.Account.HomeAccountId.Identifier -eq $acct.HomeAccountId.Identifier
})
foreach ($tok in $matchingTokens) { Disconnect-EntraEnvironment -TokenID $tok.Id }
Remove-MSALAccount -Account $acct
# Walk up to the StackPanel (row.Parent) and drop the row.
$parent = $S.Parent # the row Grid
if ($parent -and $parent.Parent) {
[void]$parent.Parent.Children.Remove($parent)
}
} catch {
Write-LogError "Failed to forget account $($acct.UserName)" $_.Exception
}
Write-Status ""
}))
$row.Children.Add($forgetBtn) | Out-Null
$ParentObj.Children.Add($row) | Out-Null
} catch {
Write-LogError "Add-CachedUser failed" $_.Exception
}
}
#region Event functions
function Invoke-MSALUIEventNewAuthentication
{
[CmdLetbinding()]
param($TokenInfo)
# Provider-neutral tenant/user sync and the ScopeTags/AssignmentFilters
# dependency-cache preload used to happen here. Both moved to
# Invoke-AuthCoreOnNewToken in Internal/AuthenticationCore.ps1, which
# subscribes to the same "AuthenticatedNewToken" event and runs regardless
# of UI mode -- headless flows (OAuth AppId+Secret in Azure Automation)
# get the same state updates that were previously UI-exclusive.
#
# This handler now does only UI-specific work: MSAL profile enrichment
# (Graph /me, profile photo, JWT-derived app name) and the auth-info bar
# refresh. Update-MSALUserProfile self-guards on $script:MSALDefaultToken
# so it's a no-op for non-MSAL providers.
$ui = $script:UIProvider
if($TokenInfo.IsDefault) {
Update-MSALUserProfile
$ui.ShowAuthenticationInfo()
}
}
function Invoke-MSALUIEventTokenRefreshed
{
[CmdLetbinding()]
param($TokenInfo)
# A silent renewal keeps the same user/tenant, so skip the heavy profile
# enrichment (Graph /me + photo) and full object reload that a NEW token
# triggers - just redraw the auth-info bar so the shown token expiry reflects
# the renewed token. Only the default token drives the visible profile.
if($TokenInfo.IsDefault) {
$ui = $script:UIProvider
if($ui) {
try { $ui.ShowAuthenticationInfo() }
catch { Write-LogError "ShowAuthenticationInfo failed on token refresh" $_.Exception }
}
}
}
function Invoke-MSALUIEventUserDisconnected
{
[CmdLetbinding()]
param($TokenInfo)
$ui = $script:UIProvider
if($TokenInfo -and $TokenInfo.TenantID) {
try { Clear-TenantCache -TenantId $TokenInfo.TenantID }
catch { Write-LogError "Failed to clear tenant cache on disconnect" $_.Exception }
}
Get-MSALUserInfo
$ui.ShowAuthenticationInfo()
}
function Invoke-MSALUIEventAuthenticationFailed
{
$ui = $script:UIProvider
# Best-effort enrichment; must never stop the redraw. On a hard failure the
# redraw reverts the avatar to the Sign-in button (Get-AvaloniaUserProfile's
# expired/no-token branch). Guard so a throw can't skip ShowAuthenticationInfo.
try { Get-MSALUserInfo } catch { Write-LogError "Get-MSALUserInfo failed on AuthenticationFailed" $_.Exception }
$ui.ShowAuthenticationInfo()
# Also refresh the environment/tenant badge so it hides when the session is gone
# (Set-EnvironmentInfo's expiry guard does the hiding). Explicit here so it doesn't
# depend on the enrichment above reaching its own SetEnvironmentInfo call.
try { $ui.SetEnvironmentInfo() } catch { Write-LogError "SetEnvironmentInfo failed on AuthenticationFailed" $_.Exception }
}
function Invoke-MSALUIAppInitialized
{
Add-AppEventHandler "AuthenticatedNewToken" "Invoke-MSALUIEventNewAuthentication"
Add-AppEventHandler "AuthenticationTokenRefresh" "Invoke-MSALUIEventTokenRefreshed"
Add-AppEventHandler "AuthenticationUserDisconnected" "Invoke-MSALUIEventUserDisconnected"
Add-AppEventHandler "AuthenticationFailed" "Invoke-MSALUIEventAuthenticationFailed"
}
#endregion