IntuneManagement 4.0.0-beta1

This commit is contained in:
Mikael Karlsson
2026-09-23 19:13:09 +10:00
commit 7869619510
892 changed files with 577109 additions and 0 deletions
+879
View File
@@ -0,0 +1,879 @@
# Provider-agnostic authentication core.
#
# This file owns the provider registry, the central token registry, and the
# provider-agnostic facade functions. Each provider self-registers at load: MSAL in
# Invoke-MSALInitialize, OAuth in Invoke-OAuthProviderInitialize, MgGraph in
# Invoke-MgGraphProviderInitialize. The user picks the active provider via the
# ActiveAuthProvider setting.
#
# Consumers are provider-agnostic: Invoke-MSGraphAPI resolves each call's owning
# provider by TokenId (Resolve-AuthTokenProvider) and gets the bearer from it (or lets
# the provider run the request), so MSAL, OAuth and MgGraph tokens can be live at once
# across different tenants.
# Provider registry. Keyed by provider Id ("MSAL", "MgGraph", "Az", ...).
$script:AuthProviders = @{}
$script:ActiveAuthProviderId = $null
# ---- Central token registry (provider-agnostic) ----
# Single source of truth for live tokens across ALL providers. Solves three
# problems that per-provider stores can't: (1) globally-unique token ids (MSAL
# and OAuth otherwise both allocate from 1 and collide); (2) id -> owning
# provider routing so a Graph call reaches the token's own provider, not just
# whichever provider is "active"; (3) one place that fires the auth events with
# one payload shape ([IMAuthToken]).
#
# Records hold only the stable routing essentials; the full IMAuthToken is
# hydrated on read from the owning provider's GetUserInfo(TokenId) so ExpiresOn
# / TenantName never go stale and token refresh needs no registry write.
$script:AuthTokens = @{} # [int]TokenId -> @{ TokenId; ProviderId; Cloud; IsDefault }
$script:AuthTokenNextId = 1 # central monotonic id allocator (feeds every provider)
$script:DefaultAuthTokenId = 0 # 0 = no default
# Add the events first so providers can fire them during their Initialize() call
# without races. Idempotent — Add-AppEvent is safe to call repeatedly.
Add-AppEvent "AuthProviderRegistered"
Add-AppEvent "ActiveAuthProviderChanged"
# ---- Common Entra app + cloud data (provider-neutral) ----
# Shared by every auth provider: MSAL and OAuth resolve their public client from
# Get-EntraApp; MgGraph and the UI cloud pickers read $script:Clouds; everyone
# calls Get-DefaultCloud. NOTE: these arrays MUST stay above the Authentication
# settings registrations below - the EntraApp / DefaultCloud List settings capture
# them as ItemsSource at dot-source time.
$script:EntraApps = @(
(New-Object PSObject -Property @{Name = ""; ClientId = ""; RedirectUri = ""; Authority = "" }),
(New-Object PSObject -Property @{Name = "*** Do NOT use *** Microsoft Intune PowerShell"; ClientId = "d1ddf0e4-d672-4dae-b554-9d5bdfd93547"; RedirectUri = "urn:ietf:wg:oauth:2.0:oob"; }),
(New-Object PSObject -Property @{Name = "Microsoft Graph PowerShell"; ClientId = "14d82eec-204b-4c2f-b7e8-296a70dab67e"; RedirectUri = "https://login.microsoftonline.com/common/oauth2/nativeclient"; })
)
$script:DefaultEntraAppId = "14d82eec-204b-4c2f-b7e8-296a70dab67e"
$script:EntraEnvironments = @(
[PSCustomObject]@{
Name = "Entra Public"
Value = "public"
URL = "login.microsoftonline.com"
GraphURL = "graph.microsoft.com"
},
[PSCustomObject]@{
Name = "Entra US Government"
Value = "usGov"
URL = "login.microsoftonline.us"
},
[PSCustomObject]@{
Name = "Entra China"
Value = "china"
URL = "login.partner.microsoftonline.cn"
GraphURL = "microsoftgraph.chinacloudapi.cn"
}
)
# Phase 1 of the cloud redesign (2026-05-22): single Cloud enum replaces the awkward
# GraphEnvironment+GCCType pair. Each entry carries everything a provider needs:
# AADAuthority — login host used to build MSAL .WithAuthority("https://<host>/<tenant>")
# GraphHost — Graph hostname for absolute @odata.bind / @odata.id URLs
# ArmHost — Azure Resource Manager hostname the tenant list is READ from
# ArmAudiences — the OAuth resource identifier(s) an ARM token is REQUESTED on, most
# likely first. Not the same thing as ArmHost: they happen to be the
# same string in the public cloud, but a sovereign tenant may only
# know the older management.core.* identifier, so both are listed and
# Internal/EntraTenantList.ps1 falls back in order.
# MgEnvironment — Connect-MgGraph -Environment value (Global / USGov / USGovDOD / China)
# LegacyEnv — old GraphEnvironment value (public / usGov / china) — used by the
# LegacyGCC — old GCCType value (gccHigh / gccDoD / $null) — deprecation translator
# "GCC" (commercial Graph from a Gov tenant) collapses into Public: same endpoints,
# no separate cloud entry needed.
$script:Clouds = @(
[PSCustomObject]@{
Value = "Public"
Name = "Public (Global)"
AADAuthority = "login.microsoftonline.com"
GraphHost = "graph.microsoft.com"
ArmHost = "management.azure.com"
ArmAudiences = @("https://management.azure.com", "https://management.core.windows.net")
MgEnvironment = "Global"
LegacyEnv = "public"
LegacyGCC = $null
},
[PSCustomObject]@{
Value = "USGov"
Name = "US Government (GCC High)"
AADAuthority = "login.microsoftonline.us"
GraphHost = "graph.microsoft.us"
ArmHost = "management.usgovcloudapi.net"
ArmAudiences = @("https://management.usgovcloudapi.net", "https://management.core.usgovcloudapi.net")
MgEnvironment = "USGov"
LegacyEnv = "usGov"
LegacyGCC = "gccHigh"
},
[PSCustomObject]@{
Value = "USGovDOD"
Name = "US Government (DoD)"
AADAuthority = "login.microsoftonline.us"
GraphHost = "dod-graph.microsoft.us"
ArmHost = "management.usgovcloudapi.net"
ArmAudiences = @("https://management.usgovcloudapi.net", "https://management.core.usgovcloudapi.net")
MgEnvironment = "USGovDOD"
LegacyEnv = "usGov"
LegacyGCC = "gccDoD"
},
[PSCustomObject]@{
Value = "China"
Name = "China (Vianet)"
AADAuthority = "login.partner.microsoftonline.cn"
GraphHost = "microsoftgraph.chinacloudapi.cn"
ArmHost = "management.chinacloudapi.cn"
ArmAudiences = @("https://management.chinacloudapi.cn", "https://management.core.chinacloudapi.cn")
MgEnvironment = "China"
LegacyEnv = "china"
LegacyGCC = $null
}
)
function Get-CloudByValue
{
param([string]$Value)
if(-not $Value) { $Value = "Public" }
$entry = $script:Clouds | Where-Object Value -eq $Value | Select-Object -First 1
if(-not $entry) {
Write-Log "Unknown cloud '$Value' - falling back to Public" 2
$entry = $script:Clouds | Where-Object Value -eq "Public" | Select-Object -First 1
}
return $entry
}
function Convert-LegacyToCloud
{
# Translate the historical (GraphEnvironment, GCCType) pair to a new Cloud value.
# Used during the deprecation window so callers passing -GraphEnvironment/-GCCType
# still land on the correct sovereign cloud after we route through the flat enum.
param(
[string]$GraphEnvironment,
[string]$GCCType
)
if(-not $GraphEnvironment) { return "Public" }
foreach($c in $script:Clouds) {
$cGcc = if($c.LegacyGCC) { $c.LegacyGCC } else { "" }
$inGcc = if($GCCType) { $GCCType } else { "" }
if($c.LegacyEnv -eq $GraphEnvironment -and $cGcc -eq $inGcc) {
return $c.Value
}
}
# usGov without gccHigh/gccDoD historically meant commercial Graph from a Gov
# tenant (the legacy "gcc" value). Collapse to Public — same endpoints.
if($GraphEnvironment -eq "usGov") { return "Public" }
return "Public"
}
function Get-DefaultCloud
{
# Persisted default cloud. Falls back to Public if the setting is missing
# or empty (corrupted config).
$val = Get-SettingValue "DefaultCloud" "Public"
if(-not $val) { return "Public" }
return $val
}
function Set-DefaultCloud
{
# Single write path for the DefaultCloud setting. UI code must use this instead
# of Save-SettingStoreValue with a hardcoded path, so reader (Get-DefaultCloud via the
# setting's SubPath) and writers always agree on Authentication\DefaultCloud.
param([Parameter(Mandatory)][string]$Value)
Save-SettingStoreValue "Authentication" "DefaultCloud" $Value
}
# --- Per-tenant cloud memory ---
function Resolve-CloudFromIss
{
# Best-effort Cloud detection from a JWT 'iss' (issuer) claim. We can't tell
# USGov (GCC High) from USGovDOD purely from iss — both issue from
# login.microsoftonline.us / sts.windows.us. When the caller already knows which
# USGov variant was requested (via -FallbackCloud), that wins; otherwise we
# default to USGov (the more common GCC variant). The same logic applies to any
# other ambiguous family.
param(
[string]$Iss,
[string]$FallbackCloud
)
if(-not $Iss) { return $FallbackCloud }
$low = $Iss.ToLowerInvariant()
if($low -match "microsoftonline\.us|sts\.windows\.us") {
if($FallbackCloud -eq "USGovDOD") { return "USGovDOD" }
return "USGov"
}
if($low -match "partner\.microsoftonline\.cn|chinacloudapi\.cn") {
return "China"
}
if($low -match "microsoftonline\.com|sts\.windows\.net|login\.microsoft\.com") {
return "Public"
}
return $FallbackCloud
}
function Save-TenantCloud
{
# Persist the cloud a given tenant was authenticated against. Used at silent
# refresh + next-launch resume so we hit the right authority without re-asking.
param([string]$TenantId, [string]$Cloud)
if(-not $TenantId -or -not $Cloud) { return }
Save-SettingStoreValue "" "TenantCloud_$TenantId" $Cloud
}
function Get-TenantCloud
{
param([string]$TenantId)
if(-not $TenantId) { return $null }
$val = Get-SettingStoreValue "" "TenantCloud_$TenantId" ""
if(-not $val) { return $null }
return [string]$val
}
function Get-StartupCloudHint
{
# Resolution chain for a sign-in that doesn't carry an explicit cloud:
# 1. TenantCloud_<TenantId> if the caller knows the target tenant
# 2. LastLoggedOnCloud (the cloud of the most recent default sign-in)
# 3. DefaultCloud setting (user-configured default)
# Returns a Cloud value (Public/USGov/USGovDOD/China) — never $null.
param([string]$TenantId)
if($TenantId) {
$t = Get-TenantCloud $TenantId
if($t) { return $t }
}
$last = Get-SettingStoreValue "" "LastLoggedOnCloud" ""
if($last) { return [string]$last }
return (Get-DefaultCloud)
}
function New-EntraApp {
param($ClientId, $TenantId, $RedirectUri, $Authority)
return New-Object PSObject -Property @{
ClientId = $ClientId
TenantId = $TenantId
RedirectUri = $RedirectUri
Authority = $Authority
}
}
function Get-EntraApp {
[CmdletBinding()]
param($AppId,
$RedirectURI,
$Authority)
# Resolution order, highest priority first:
# 1. Caller-supplied $AppId (explicit override).
# 2. "EntraApp" setting (the Settings → Authentication → Entra application
# dropdown — picks one of the built-in apps in $script:EntraApps).
# 3. "EntraCustomAppId" setting (the Settings → Authentication → Application Id
# field — a custom Entra App registration the user controls).
# 4. $script:DefaultEntraAppId (Microsoft Graph PowerShell first-party app).
# The previous version defaulted $entraAppId to the Microsoft Graph PowerShell
# GUID when the dropdown was empty, which then matched a built-in entry and
# never fell through to EntraCustomAppId — so a user who filled in only the
# custom Application Id field still got logged in via the first-party app.
$entraAppObj = $null
if(-not [String]::IsNullOrWhiteSpace($AppId)) {
$entraAppObj = $script:EntraApps | Where-Object ClientId -eq $AppId
if(-not $entraAppObj) {
$entraAppObj = New-EntraApp -ClientId $AppId `
-TenantId (Get-SettingValue "EntraCustomTenantId") `
-RedirectUri (Get-SettingValue "EntraCustomAppRedirect" $RedirectURI) `
-Authority (Get-SettingValue "EntraCustomAuthority" $Authority)
}
}
if(-not $entraAppObj) {
$dropdownAppId = Get-SettingValue "EntraApp"
if(-not [String]::IsNullOrWhiteSpace($dropdownAppId)) {
$entraAppObj = $script:EntraApps | Where-Object ClientId -eq $dropdownAppId
}
}
if(-not $entraAppObj) {
$customAppId = Get-SettingValue "EntraCustomAppId"
if(-not [String]::IsNullOrWhiteSpace($customAppId)) {
$entraAppObj = New-EntraApp -ClientId $customAppId `
-TenantId (Get-SettingValue "EntraCustomTenantId") `
-RedirectUri (Get-SettingValue "EntraCustomAppRedirect" $RedirectURI) `
-Authority (Get-SettingValue "EntraCustomAuthority" $Authority)
}
}
if((-not $entraAppObj -or -not $entraAppObj.ClientId) -and $script:DefaultEntraAppId) {
$entraAppObj = $script:EntraApps | Where-Object ClientId -eq $script:DefaultEntraAppId
}
$entraAppObj
}
function Get-EntraEnvironment
{
[CmdletBinding()]
param($Environment = $null)
if([String]::IsNullOrEmpty($Environment)) { $Environment = "public"}
$msalEnvironment = $script:EntraEnvironments | Where-Object Value -eq $Environment
if(-not $msalEnvironment) {
Write-Log "Entra environment $Environment not found. Using pulic" 2
}
Write-LogDebug "Use Entra environment $($msalEnvironment.Value)"
$msalEnvironment
}
# ---- end common Entra app + cloud data ----
# Setting that picks which registered provider is active. Read once at AppInitialized
# time (after every provider has had a chance to register). If the setting points at
# a provider that isn't registered (e.g. one this build doesn't ship) we keep
# whatever was set during registration — usually MSAL since it always registers.
#
# ItemsSource starts empty: this file loads before any provider registers, so the
# real list is built from the registry in Invoke-AuthCoreOnAppInitialized. Both
# settings dialogs read ItemsSource at render time, long after AppInitialized.
Add-SettingsSection -Title "Authentication" -Id "Authentication" -Order 7
Add-SettingsObject -Title "Active authentication provider" -Key "ActiveAuthProvider" -Type "List" -DefaultValue "MSAL" `
-ItemsSource @() `
-Description "Which authentication backend to use. MSAL is the built-in default. MgGraph requires the Microsoft.Graph.Authentication PowerShell module to be installed. OAuth is a pure-PowerShell provider for automation (CI / scheduled tasks / managed identity / workload identity federation) - no SDK required." `
-Section "Authentication"
# Common app-identity + cloud settings, shared by every provider (moved here from
# the former Entra section). MSAL and OAuth resolve their public client from these
# via Get-EntraApp - they are two ways of authenticating with the SAME app.
Add-SettingsObject -Title "Entra application" -Key "EntraApp" -Type "List" -SelectedValuePath "ClientId" -ItemsSource $script:EntraApps `
-Description "Built-in Entra application used for interactive sign-in by both the MSAL and OAuth providers. Leave empty to use the custom Application Id below, or the default Microsoft Graph PowerShell app." `
-Section "Authentication"
Add-SettingsObject -Title "Application Id" -Key "EntraCustomAppId" -Type "String" `
-Description "Custom Entra application (client) id used by MSAL and OAuth sign-in when no built-in application is selected above. The app registration needs the http://localhost redirect URI for browser-based login." `
-Section "Authentication"
Add-SettingsObject -Title "Redirect URL" -Key "EntraCustomAppRedirect" -Type "String" `
-Section "Authentication"
Add-SettingsObject -Title "Tenant Id" -Key "EntraCustomTenantId" -Type "String" `
-Section "Authentication"
Add-SettingsObject -Title "Authority" -Key "EntraCustomAuthority" -Type "String" `
-Section "Authentication"
Add-SettingsObject -Title "Default cloud" -Key "DefaultCloud" -Type "List" -DefaultValue "Public" `
-SelectedValuePath "Value" -ItemsSource $script:Clouds `
-Description "Microsoft cloud this tool signs in to by default. Public covers commercial + GCC commercial; USGov is GCC High; USGovDOD is GCC DoD; China is the Vianet cloud." `
-Section "Authentication"
Add-SettingsObject -Title "Interactive login timeout (seconds)" -Key "MSGraphInteractiveTimeoutSec" -Type "Int" -DefaultValue 600 `
-Description "Maximum time to wait for an interactive login to complete (MSAL embedded/broker and OAuth browser flows). Only applies to flows a user is waiting in front of; silent and app-secret token requests have their own much shorter cap. The default was 180 s, which killed legitimate sign-ins that needed an account picker plus an MFA approval - and the sign-in status has a Cancel button, so an abandoned window does not depend on this timeout." `
-Section "Authentication"
function Register-AuthProvider {
<#
.SYNOPSIS
Register an authentication provider in the multi-provider auth core.
.DESCRIPTION
The provider must inherit from AuthenticationProvider and set its Id. The
first provider registered becomes the active one automatically; pass
-SetActive to force-switch.
#>
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[AuthenticationProvider]$Provider,
[switch]$SetActive
)
if(-not $Provider.Id) {
Write-Log "Refusing to register an AuthenticationProvider without an Id" 3
return
}
if($script:AuthProviders.ContainsKey($Provider.Id)) {
Write-Log "AuthenticationProvider '$($Provider.Id)' is already registered; replacing" 2
}
$script:AuthProviders[$Provider.Id] = $Provider
Write-Log "Registered authentication provider: $($Provider.Id) - $($Provider.DisplayName)"
try { $Provider.Initialize() }
catch { Write-LogError "Provider $($Provider.Id) Initialize() threw" $_.Exception }
Invoke-AppEvent "AuthProviderRegistered" $Provider | Out-Null
if($SetActive -or -not $script:ActiveAuthProviderId) {
Set-ActiveAuthProvider -Id $Provider.Id
}
}
function Get-AuthProvider {
<#
.SYNOPSIS
Get a registered authentication provider by Id, or the active one if -Id is
omitted.
#>
[CmdletBinding()]
[OutputType([AuthenticationProvider])]
param([string]$Id)
if(-not $Id) { $Id = $script:ActiveAuthProviderId }
if(-not $Id) { return $null }
if(-not $script:AuthProviders.ContainsKey($Id)) { return $null }
return $script:AuthProviders[$Id]
}
function Get-RegisteredAuthProviders {
<#
.SYNOPSIS
List every registered authentication provider.
#>
return @($script:AuthProviders.Values)
}
# Accessor backing the -Provider ArgumentCompleter on Connect-IntuneManagement /
# Get-AuthToken (invoked via & (Get-Module IntuneManagement) { Get-AuthProviderValues }).
# A [ValidateSet([IValidateSetValuesGenerator])] was deliberately avoided so the
# module still imports on Windows PowerShell 5.1 (that interface is PS7-only).
# Returns the sorted list of registered provider Ids (MSAL, MgGraph, OAuth, ...).
function Get-AuthProviderValues {
if (-not $script:AuthProviders -or $script:AuthProviders.Count -eq 0) { return @() }
return @($script:AuthProviders.Keys | Sort-Object)
}
# ===================== Central token registry =====================
# See the $script:AuthTokens comment block at the top of this file.
# The single global token-id allocator. Every provider draws its token id from
# here instead of its own counter, so ids are unique across MSAL / OAuth / MgGraph.
function Get-NextAuthTokenId {
$id = $script:AuthTokenNextId
$script:AuthTokenNextId = $id + 1
return $id
}
# Hydrate an [IMAuthToken] for a registry record: ask the owning provider for the
# live identity, then stamp the registry-authoritative fields (TokenId, Provider,
# Cloud, IsDefault). Tolerates a provider that returns $null (e.g. torn-down
# session) by falling back to the stored record fields — never triggers auth.
function ConvertTo-IMAuthToken {
param([Parameter(Mandatory)][hashtable]$Record)
$provider = $null
if ($Record.ProviderId -and $script:AuthProviders.ContainsKey($Record.ProviderId)) {
$provider = $script:AuthProviders[$Record.ProviderId]
}
$info = $null
if ($provider) {
try { $info = $provider.GetUserInfo($Record.TokenId) }
catch { Write-LogDebug "ConvertTo-IMAuthToken: GetUserInfo failed on '$($Record.ProviderId)' for token $($Record.TokenId): $($_.Exception.Message)" }
}
$token = if ($info -is [IMAuthToken]) { $info } else { [IMAuthToken]::new() }
if ($info -and $info -isnot [IMAuthToken]) {
# Provider still returns a loose PSCustomObject (pre-migration) - copy the
# known fields across so callers always get a typed IMAuthToken.
foreach ($p in 'TenantId','TenantName','Account','UPN','UserId','AppId','AppName','AuthType','ExpiresOn') {
if ($info.PSObject.Properties[$p]) { $token.$p = $info.$p }
}
if (-not $token.Account -and $info.PSObject.Properties['DisplayName']) { $token.Account = $info.DisplayName }
}
# Registry-authoritative overlay.
$token.TokenId = [int]$Record.TokenId
$token.Provider = [string]$Record.ProviderId
if ($Record.Cloud) { $token.Cloud = [string]$Record.Cloud }
$token.IsDefault = ($script:DefaultAuthTokenId -eq $Record.TokenId)
return $token
}
# Return the [IMAuthToken] for one id, or $null when unknown.
function Get-AuthTokenById {
param([int]$TokenId)
if (-not $script:AuthTokens.ContainsKey($TokenId)) { return $null }
return ConvertTo-IMAuthToken -Record $script:AuthTokens[$TokenId]
}
# Return every live token as [IMAuthToken[]], ordered by id.
function Get-AuthTokenList {
if (-not $script:AuthTokens -or $script:AuthTokens.Count -eq 0) { return [IMAuthToken[]]@() }
$rows = foreach ($id in ($script:AuthTokens.Keys | Sort-Object)) {
ConvertTo-IMAuthToken -Record $script:AuthTokens[$id]
}
return [IMAuthToken[]]@($rows)
}
function Get-DefaultAuthTokenId {
return $script:DefaultAuthTokenId
}
# Make a token the default. IsDefault everywhere derives from this single value,
# so there is no per-record flag to keep in sync.
function Set-DefaultAuthToken {
param([Parameter(Mandatory)][int]$TokenId)
if (-not $script:AuthTokens.ContainsKey($TokenId)) {
Write-Log "Set-DefaultAuthToken: token $TokenId is not registered" 2
return
}
$script:DefaultAuthTokenId = $TokenId
}
# Resolve a token id to its owning [AuthenticationProvider]; $null when unknown
# (callers fall back to the active provider for id 0 / unregistered ids).
function Resolve-AuthTokenProvider {
param([int]$TokenId)
if ($TokenId -le 0) { return $null }
if (-not $script:AuthTokens.ContainsKey($TokenId)) { return $null }
$providerId = $script:AuthTokens[$TokenId].ProviderId
if ($providerId -and $script:AuthProviders.ContainsKey($providerId)) {
return $script:AuthProviders[$providerId]
}
return $null
}
# Register a freshly-acquired token. Providers call this after storing the token
# in their own backend, passing the id they drew from Get-NextAuthTokenId. Sets
# the first token (or an explicitly-defaulted one) as default and fires
# AuthenticatedNewToken with the hydrated [IMAuthToken]. Returns that token.
function Register-AuthToken {
# -Provider is intentionally untyped: only $Provider.Id is used here (hydration
# later resolves the live provider from $script:AuthProviders by that id). Keeping
# it untyped matches how the codebase injects fake providers in tests.
param(
[Parameter(Mandatory)]$Provider,
[Parameter(Mandatory)][int]$TokenId,
[string]$Cloud,
[switch]$Default
)
$script:AuthTokens[$TokenId] = @{
TokenId = $TokenId
ProviderId = $Provider.Id
Cloud = $Cloud
}
if ($Default -or $script:DefaultAuthTokenId -le 0) {
$script:DefaultAuthTokenId = $TokenId
}
$token = ConvertTo-IMAuthToken -Record $script:AuthTokens[$TokenId]
try { Invoke-AppEvent "AuthenticatedNewToken" $token | Out-Null } catch { }
return $token
}
# Remove a token from the registry. Fires AuthenticationUserDisconnected with a
# final snapshot; if the removed token was the default and others remain, promotes
# the highest-id survivor and fires AuthenticatedNewToken for it.
function Unregister-AuthToken {
param([Parameter(Mandatory)][int]$TokenId)
if (-not $script:AuthTokens.ContainsKey($TokenId)) { return }
$snapshot = ConvertTo-IMAuthToken -Record $script:AuthTokens[$TokenId]
$wasDefault = ($script:DefaultAuthTokenId -eq $TokenId)
$script:AuthTokens.Remove($TokenId) | Out-Null
$promoted = $null
if ($wasDefault) {
$script:DefaultAuthTokenId = 0
if ($script:AuthTokens.Count -gt 0) {
$nextId = ($script:AuthTokens.Keys | Sort-Object -Descending | Select-Object -First 1)
$script:DefaultAuthTokenId = $nextId
$promoted = ConvertTo-IMAuthToken -Record $script:AuthTokens[$nextId]
}
}
try { Invoke-AppEvent "AuthenticationUserDisconnected" $snapshot | Out-Null } catch { }
if ($promoted) {
try { Invoke-AppEvent "AuthenticatedNewToken" $promoted | Out-Null } catch { }
}
}
# Fire AuthenticationTokenRefresh for a token after a silent renewal. No registry
# write needed (IMAuthToken is hydrated on read); this just notifies consumers.
function Update-AuthToken {
param([Parameter(Mandatory)][int]$TokenId)
if (-not $script:AuthTokens.ContainsKey($TokenId)) { return }
$token = ConvertTo-IMAuthToken -Record $script:AuthTokens[$TokenId]
try { Invoke-AppEvent "AuthenticationTokenRefresh" $token | Out-Null } catch { }
}
# Fire AuthenticationFailed with a canonical shape, so every provider reports
# failures the same way (replaces ad-hoc Exception / $null / raw-object payloads).
function Invoke-AuthTokenFailed {
param(
[string]$Provider,
[string]$TenantId,
[string]$ErrorCode,
[string]$Message,
$Exception
)
$payload = [PSCustomObject]@{
Provider = $Provider
TenantId = $TenantId
ErrorCode = $ErrorCode
Message = if ($Message) { $Message } elseif ($Exception) { $Exception.Message } else { $null }
Exception = $Exception
}
try { Invoke-AppEvent "AuthenticationFailed" $payload | Out-Null } catch { }
}
# True when the current default (signed-in) token has passed its expiry with no
# valid replacement. Provider-agnostic: uses the owning provider's
# GetAccessTokenExpiry(), which returns [datetime]::MaxValue when expiry is
# unknown or the SDK manages refresh (MgGraph) - those are treated as NOT expired
# so we never falsely flip a still-managed session to a signed-out UI. Returns
# $false when nothing is signed in. Used by the UI to decide when the title-bar
# profile control should fall back to the Sign-in icon.
function Test-DefaultTokenExpired {
try {
if (-not (Get-Command Get-AuthProvider -ErrorAction SilentlyContinue)) { return $false }
$provider = Get-AuthProvider
$tokenId = Get-DefaultTokenId
if (-not $provider -or -not $tokenId) { return $false }
$exp = $provider.GetAccessTokenExpiry($tokenId, "https://graph.microsoft.com")
return ($exp -ne [datetime]::MaxValue -and $exp -le (Get-Date))
}
catch { return $false }
}
# ===================== end token registry =====================
function Set-ActiveAuthProvider {
<#
.SYNOPSIS
Make a registered provider the active one. Subsequent calls to Get-AuthProvider
(without -Id) return this provider.
#>
[CmdletBinding()]
param([Parameter(Mandatory)][string]$Id)
if(-not $script:AuthProviders.ContainsKey($Id)) {
Write-Log "Cannot set active auth provider '$Id' - not registered" 3
return
}
$previous = $script:ActiveAuthProviderId
$script:ActiveAuthProviderId = $Id
Write-Log "Active authentication provider: $Id"
if($previous -ne $Id) {
Invoke-AppEvent "ActiveAuthProviderChanged" $script:AuthProviders[$Id] $previous | Out-Null
}
}
# === Facades that route to the ACTIVE provider ===
# Convenience wrappers for callers that just want "the current provider". The hot path
# (Invoke-MSGraphAPI) does NOT use these: it resolves each call's OWNING provider by
# TokenId (Resolve-AuthTokenProvider) so a call reaches the token's own provider, not
# merely whichever provider is active.
function Get-AuthProviderAccessToken {
[CmdletBinding()]
[OutputType([string])]
param(
[int]$TokenId,
[string]$Resource = "https://graph.microsoft.com",
[string]$ProviderId
)
$provider = Get-AuthProvider $ProviderId
if(-not $provider) { return $null }
return $provider.GetAccessToken($TokenId, $Resource)
}
function Get-AuthProviderUserInfo {
[CmdletBinding()]
[OutputType([PSCustomObject])]
param([int]$TokenId, [string]$ProviderId)
$provider = Get-AuthProvider $ProviderId
if(-not $provider) { return $null }
return $provider.GetUserInfo($TokenId)
}
function Get-AuthProviderCachedAccounts {
[CmdletBinding()]
[OutputType([PSCustomObject[]])]
param([string]$ProviderId)
$provider = Get-AuthProvider $ProviderId
if(-not $provider) { return [PSCustomObject[]]@() }
return $provider.GetCachedAccounts()
}
# --- Active-tenant / current-user accessors (architecture R7) ---
# The org/tenant identifiers and the signed-in user are auth-module state
# (set by the authentication providers on every token change). Non-auth code
# must read them through these accessors instead of reaching into the module's
# internal $script: variables directly. The values are module-scope, so these
# are thin getters; the point is encapsulation - if the backing field is ever
# renamed or sourced differently, only these change.
function Get-CurrentTenantId {
[OutputType([string])]
param()
return $script:OrganizationId
}
function Get-CurrentOrganizationName {
[OutputType([string])]
param()
return $script:OrganizationName
}
function Get-CurrentUser {
# The signed-in user object (Graph /me shape when available; may be a
# display-name string for app/secret logins, or $null when signed out).
param()
return $script:CurrentUser
}
function Get-AuthProviderAvailableTenants {
[CmdletBinding()]
[OutputType([PSCustomObject[]])]
param([int]$TokenId, [string]$ProviderId)
$provider = Get-AuthProvider $ProviderId
if(-not $provider) { return [PSCustomObject[]]@() }
return $provider.GetAvailableTenants($TokenId)
}
# Honour the user's "ActiveAuthProvider" setting after every provider has had a
# chance to register. AppInitialized fires near the end of module load — by then
# both MSAL and (if installed) MgGraph have registered themselves.
function Invoke-AuthCoreOnAppInitialized {
# Populate the ActiveAuthProvider setting's dropdown from the registry now that
# every provider has registered. MSAL first (it's the default), rest by Id.
$settingObj = (Get-SettingsSection "Authentication").Values | Where-Object Key -eq "ActiveAuthProvider"
if($settingObj) {
$settingObj.ItemsSource = @(Get-RegisteredAuthProviders |
Sort-Object @{ Expression = { $_.Id -ne "MSAL" } }, Id |
ForEach-Object { [PSCustomObject]@{ Name = $_.DisplayName; Value = $_.Id } })
}
# IM_AUTH_PROVIDER picks the provider for this session only (launch configs,
# "start with OAuth" vs "start with MSAL"); it outranks the persisted setting
# but never writes it, so the next plain launch is back on the saved choice.
$desired = $env:IM_AUTH_PROVIDER
$source = "environment (IM_AUTH_PROVIDER)"
if(-not $desired) {
$desired = Get-SettingValue "ActiveAuthProvider"
$source = "setting"
}
if($desired) {
if($script:AuthProviders.ContainsKey($desired)) {
if($script:ActiveAuthProviderId -ne $desired) {
Write-Log "Active auth provider switched to '$desired' per $source"
Set-ActiveAuthProvider -Id $desired
}
}
else {
Write-Log "$source requested auth provider '$desired' but it isn't registered (active = '$script:ActiveAuthProviderId')" 2
}
}
# Give the active provider a chance to silently resume a persisted session at
# startup (no interactive prompt). MSAL overrides TryResumeSession to do a silent
# Connect-EntraEnvironment from its on-disk cache; MgGraph resumes its
# Azure.Identity disk cache. Providers with nothing to resume inherit the base
# no-op ($false).
$active = Get-AuthProvider
if($active) {
try {
if($active.TryResumeSession()) {
Write-LogDebug "Provider '$($active.Id)' resumed a persisted session at startup"
}
}
catch {
Write-LogError "Provider '$($active.Id)' TryResumeSession threw" $_.Exception
}
}
}
Add-AppEventHandler "AppInitialized" "Invoke-AuthCoreOnAppInitialized"
# Provider-neutral post-auth state sync. Reads the active provider's normalized
# GetUserInfo() shape (defined on the AuthenticationProvider base class) and
# writes the module-scope globals ($script:OrganizationId, $script:OrganizationName,
# $script:CurrentUser) that downstream code -- Get-CurrentTenantId,
# Test-DocumentationGraphAvailable, env-badge helpers, etc. -- reads. Replaces
# the MSAL-only path in Get-MSALUserInfo (now Update-MSALUserProfile) for these
# generic fields, so headless OAuth / MgGraph sessions (no UI event handlers)
# still populate the tenant id.
function Sync-AuthContextFromProvider {
[CmdletBinding()]
param([int]$TokenId = 0)
$provider = Get-AuthProvider
if(-not $provider) {
$script:OrganizationId = $null
$script:OrganizationName = $null
$script:CurrentUser = $null
return
}
$u = $null
try { $u = $provider.GetUserInfo($TokenId) }
catch { Write-LogDebug "Sync-AuthContextFromProvider: GetUserInfo failed on '$($provider.Id)': $($_.Exception.Message)" }
if(-not $u) { return }
if($u.TenantId) { $script:OrganizationId = [string]$u.TenantId }
if($u.TenantId) { $script:OrganizationName = if($u.TenantName) { [string]$u.TenantName } else { [string]$u.TenantId } }
# Minimal, provider-independent user shape. UI code that wants MSAL-specific
# enrichment (photo, JWT claim inspection) layers it on separately via
# Update-MSALUserProfile.
$script:CurrentUser = [PSCustomObject]@{
displayName = $u.DisplayName
userPrincipalName = $u.UPN
Id = $u.UserId
}
}
# Module-scope AuthenticatedNewToken handler. Fires regardless of whether the UI
# is loaded, so headless sessions get the same essential state updates the UI
# handlers used to do exclusively (organization id/name, scope-tag + filter
# preload into the persistent dependency cache).
function Invoke-AuthCoreOnNewToken {
[CmdletBinding()]
param($TokenInfo)
if(-not $TokenInfo) { return }
# Payload is an [IMAuthToken] (.TokenId) once a provider registers via the
# token registry; legacy MSAL fire sites still pass a Get-TokenInfo projection
# (.Id) until they migrate. Accept either so the handler is correct throughout
# the rollout.
$tokenId = 0
if($TokenInfo.PSObject.Properties['TokenId'] -and $TokenInfo.TokenId) { $tokenId = [int]$TokenInfo.TokenId }
elseif($TokenInfo.PSObject.Properties['Id'] -and $TokenInfo.Id) { $tokenId = [int]$TokenInfo.Id }
try { Sync-AuthContextFromProvider -TokenId $tokenId }
catch { Write-LogError 'Sync-AuthContextFromProvider failed on AuthenticatedNewToken' $_.Exception }
# Preload ScopeTags + AssignmentFilters into DependencyObjects_<TenantId> so
# downstream flows (Documentation NameFilter 'scope:...', Copy / Import
# scope-tag pickers) resolve without a mid-run Graph call. Was previously
# only invoked from the UI event handlers -- headless flows never got it.
if($tokenId -gt 0) {
try { Initialize-TenantDependencyCache -TokenId $tokenId }
catch { Write-LogError 'Initialize-TenantDependencyCache failed on AuthenticatedNewToken' $_.Exception }
}
}
# Declare the event name here in addition to the declaration in
# AuthenticationMSALHelpers.ps1. Internal/ files are dot-sourced alphabetically, so
# AuthenticationCore.ps1 loads BEFORE AuthenticationMSALHelpers.ps1 — without this
# call, Add-AppEventHandler below silently fails because the event doesn't yet
# exist in $script:AppEventTriggers. Add-AppEvent is idempotent (checks
# ContainsKey), so re-declaring in Invoke-MSALInitialize is harmless.
# Add Authentication Events
Add-AppEvent "AuthenticatedNewToken"
Add-AppEvent "AuthenticationTokenRefresh"
Add-AppEvent "AuthenticationUserDisconnected"
Add-AppEvent "AuthenticationFailed"
Add-AppEventHandler "AuthenticatedNewToken" "Invoke-AuthCoreOnNewToken"