mirror of
https://github.com/Micke-K/IntuneManagement.git
synced 2026-09-28 10:55:38 +02:00
IntuneManagement 4.0.0-beta1
This commit is contained in:
@@ -0,0 +1,652 @@
|
||||
#ImportOrder 26
|
||||
|
||||
# Microsoft.Graph PowerShell SDK implementation of AuthenticationProvider.
|
||||
#
|
||||
# Wraps the Microsoft.Graph.Authentication module (Connect-MgGraph, Disconnect-MgGraph,
|
||||
# Get-MgContext). The SDK itself uses MSAL.NET underneath but with its own session
|
||||
# state and its own on-disk token cache, separate from our MSAL provider — by design,
|
||||
# per the user's decision to keep caches separate.
|
||||
#
|
||||
# Status:
|
||||
# * The class always registers (even without the SDK installed) so it is selectable
|
||||
# in Settings; the SDK modules are resolved / prompted-for on first Connect.
|
||||
# * `Connect-IntuneManagement -Provider MgGraph -...` routes here.
|
||||
# * Invoke-MSGraphAPI routes requests for MgGraph-owned tokens through this provider:
|
||||
# when the SDK's opaque cache can't yield a raw bearer, the request runs via the
|
||||
# provider's own pipeline (see InvokeWebRequest / Invoke-MgGraphRequestAsWebResponse).
|
||||
#
|
||||
# Token-extraction note: the SDK does not expose the raw access token via a public
|
||||
# cmdlet. We reach into [Microsoft.Graph.PowerShell.Authentication.GraphSession]::Instance
|
||||
# which is the documented (in source) but undocumented (in MS Learn) accessor. In SDK
|
||||
# v2 the AccessToken is a SecureString; we unprotect at the last moment.
|
||||
#
|
||||
# Minimum SDK version for CAE: Microsoft.Graph.Authentication 2.37.0+ is recommended.
|
||||
# Earlier versions had a token-cache bug (fixed by PR #3573, May 2026) where the
|
||||
# `caeEnabled: true` capability was not included when caching tokens — so a CAE
|
||||
# claim-challenge round-trip could re-prompt instead of resolving silently. Older
|
||||
# SDK versions still work for non-CAE flows.
|
||||
class AuthenticationMgGraph : AuthenticationProvider {
|
||||
|
||||
AuthenticationMgGraph() {
|
||||
$this.Id = "MgGraph"
|
||||
$this.DisplayName = "Microsoft Graph PowerShell SDK"
|
||||
|
||||
# Required capabilities (see AuthenticationProvider contract).
|
||||
$this.SupportsInteractive = $true
|
||||
$this.SupportsClientSecret = $true
|
||||
$this.SupportsCertificate = $true
|
||||
|
||||
# Optional: SDK has -Identity flag for managed identity.
|
||||
$this.SupportsIdentityProvider = $true
|
||||
|
||||
# Optional: SDK accepts -AccessToken.
|
||||
$this.SupportsBYOToken = $true
|
||||
|
||||
# The SDK keeps cached accounts in a private InMemoryTokenCache byte[] (the
|
||||
# serialized MSAL-v3 cache). GetCachedAccounts() reaches in via reflection
|
||||
# and rehydrates an MSAL public-client app to enumerate the accounts —
|
||||
# source pattern: github.com/microsoftgraph/msgraph-sdk-powershell.
|
||||
# NOTE: this cache is in-memory only (NOT persisted to disk) — accounts only
|
||||
# show up within the current PowerShell session, and clicking one cannot
|
||||
# "switch to" that account because Connect-MgGraph has no -LoginHint
|
||||
# parameter. The list is informational; the user must re-Connect-MgGraph
|
||||
# to change accounts.
|
||||
$this.SupportsCachedUsers = $true
|
||||
|
||||
# SDK has no per-call tenant switching — you Disconnect and Connect with a
|
||||
# different -TenantId. The active session is single-tenant.
|
||||
$this.SupportsMultiTenant = $false
|
||||
|
||||
# The SDK refreshes internally, but a manual "Refresh" action is meaningful
|
||||
# for users — we re-trigger Connect-MgGraph (silent if the cache has a
|
||||
# valid refresh token, interactive otherwise).
|
||||
$this.SupportsRefresh = $true
|
||||
|
||||
# No way to evict a single cached account from the SDK's token cache via
|
||||
# public cmdlets. Disconnect-MgGraph clears the active session only.
|
||||
$this.SupportsForget = $false
|
||||
}
|
||||
|
||||
[void] Initialize() {
|
||||
# Module presence check happens at registration time in
|
||||
# Internal/AuthenticationMgGraphHelpers.ps1 — by the time we get here, the SDK is
|
||||
# known to be installed. We do NOT eagerly Import-Module (load cost is
|
||||
# ~hundreds of ms); the first Connect() call imports lazily.
|
||||
}
|
||||
|
||||
# The SDK v2 in-memory token cache is opaque, so we can't hand Invoke-MSGraphAPI a
|
||||
# raw bearer. Route the request through the SDK's own pipeline (which auths it) and
|
||||
# wrap the result so it quacks like Invoke-WebRequest's response.
|
||||
[object] InvokeWebRequest([string]$Url, [string]$Method, [object]$Body, [hashtable]$Headers) {
|
||||
return (Invoke-MgGraphRequestAsWebResponse -Url $Url -Method $Method -Body $Body -Headers $Headers)
|
||||
}
|
||||
|
||||
# Native session inspector rows for the profile "Session Info" dialog: Get-MgContext
|
||||
# properties (the closest MgGraph equivalent of MSAL's AuthenticationResult).
|
||||
[PSCustomObject[]] GetSessionInfoRows() {
|
||||
$rows = @()
|
||||
try {
|
||||
$ctx = Get-MgContext -ErrorAction SilentlyContinue
|
||||
if($ctx) {
|
||||
foreach($prop in ($ctx | Get-Member -MemberType Properties)) {
|
||||
$value = $ctx."$($prop.Name)"
|
||||
if($prop.Name -eq "Scopes" -and $value) { $value = ($value -join "`n") }
|
||||
if($value -is [SecureString]) { $value = "<SecureString>" }
|
||||
$rows += [PSCustomObject]@{ Name = $prop.Name; Value = $value }
|
||||
}
|
||||
}
|
||||
} catch { }
|
||||
return [PSCustomObject[]]$rows
|
||||
}
|
||||
|
||||
# Silent cross-session resume. Microsoft.Graph SDK v2 persists credentials by
|
||||
# default (ContextScope.CurrentUser): the MSAL cache lives at
|
||||
# %LOCALAPPDATA%\.IdentityService\mg.msal.cache and the AuthenticationRecord
|
||||
# anchor at %USERPROFILE%\.mg\mg.authrecord.json. Both must exist; if so, a
|
||||
# plain Connect-MgGraph -NoWelcome silently rehydrates the session via
|
||||
# Azure.Identity's MsalCacheHelper. No browser, no prompt.
|
||||
[bool] TryResumeSession() {
|
||||
try {
|
||||
if(-not (Resolve-MgGraphModule)) { return $false }
|
||||
|
||||
$anchor = Join-Path $env:USERPROFILE ".mg\mg.authrecord.json"
|
||||
if(-not (Test-Path $anchor)) {
|
||||
Write-LogDebug "MgGraph: no auth record at $anchor - skipping silent resume"
|
||||
return $false
|
||||
}
|
||||
|
||||
try {
|
||||
Import-Module Microsoft.Graph.Authentication -ErrorAction Stop | Out-Null
|
||||
}
|
||||
catch {
|
||||
Write-LogError "MgGraph TryResumeSession: failed to import Microsoft.Graph.Authentication" $_.Exception
|
||||
return $false
|
||||
}
|
||||
|
||||
# If a context is already active (e.g. another caller already connected
|
||||
# during this session), respect it.
|
||||
$existing = $null
|
||||
try { $existing = Get-MgContext -ErrorAction SilentlyContinue } catch { }
|
||||
if($existing) {
|
||||
Write-Log "MgGraph: already signed in as $($existing.Account) (tenant $($existing.TenantId)); silent resume not needed"
|
||||
return $true
|
||||
}
|
||||
|
||||
Write-Log "MgGraph: attempting silent resume from persisted Azure.Identity cache..."
|
||||
|
||||
# NoWelcome suppresses banner; no Scopes parameter means Azure.Identity
|
||||
# uses whatever scopes were in the AuthenticationRecord. If the cache or
|
||||
# record is stale, Connect-MgGraph will throw / require interaction —
|
||||
# we treat any failure as "resume not possible, user must click Login".
|
||||
Connect-MgGraph -NoWelcome -ErrorAction Stop | Out-Null
|
||||
|
||||
$ctx = $null
|
||||
try { $ctx = Get-MgContext -ErrorAction SilentlyContinue } catch { }
|
||||
if($ctx) {
|
||||
Write-Log "MgGraph: silent resume succeeded - signed in as $($ctx.Account) (tenant $($ctx.TenantId))"
|
||||
return $true
|
||||
}
|
||||
Write-Log "MgGraph: Connect-MgGraph completed but Get-MgContext returned nothing - silent resume failed" 2
|
||||
return $false
|
||||
}
|
||||
catch {
|
||||
Write-LogDebug "MgGraph: silent resume failed: $($_.Exception.Message)"
|
||||
return $false
|
||||
}
|
||||
}
|
||||
|
||||
[PSCustomObject] Connect([hashtable]$Arguments) {
|
||||
Write-Log "AuthenticationMgGraph.Connect starting"
|
||||
|
||||
# Step 1: ensure the required SDK module is available. If not, offer to
|
||||
# install it. If the user declines or install fails, return $null so
|
||||
# Connect-IntuneManagement can fall back to MSAL.
|
||||
if(-not (Resolve-MgGraphModule)) {
|
||||
Write-Log "Microsoft.Graph.Authentication not available - MgGraph provider cannot connect" 2
|
||||
return $null
|
||||
}
|
||||
|
||||
try {
|
||||
Import-Module Microsoft.Graph.Authentication -ErrorAction Stop | Out-Null
|
||||
}
|
||||
catch {
|
||||
Write-LogError "Failed to import Microsoft.Graph.Authentication" $_.Exception
|
||||
return $null
|
||||
}
|
||||
|
||||
$mgArgs = @{ NoWelcome = $true }
|
||||
|
||||
if($Arguments.TenantId) { $mgArgs['TenantId'] = $Arguments.TenantId }
|
||||
if($Arguments.AppId) { $mgArgs['ClientId'] = $Arguments.AppId }
|
||||
|
||||
# Cloud / sovereign environment selection. Prefer the flat -Cloud arg
|
||||
# (Phase 1, 2026-05-22). Fall back to translating the legacy GraphEnvironment+GCCType
|
||||
# pair so direct provider callers passing the old shape still work during the
|
||||
# deprecation window. Connect-MgGraph -Environment accepts: Global / USGov / USGovDOD / China.
|
||||
$cloudValue = if($Arguments.Cloud) { [string]$Arguments.Cloud }
|
||||
elseif($Arguments.GraphEnvironment -or $Arguments.GCCType) {
|
||||
Convert-LegacyToCloud -GraphEnvironment ([string]$Arguments.GraphEnvironment) -GCCType ([string]$Arguments.GCCType)
|
||||
}
|
||||
else { "Public" }
|
||||
|
||||
$cloudEntry = Get-CloudByValue $cloudValue
|
||||
$mgEnv = $cloudEntry.MgEnvironment
|
||||
if($mgEnv -and $mgEnv -ne "Global") {
|
||||
$mgArgs['Environment'] = $mgEnv
|
||||
Write-LogDebug "MgGraph: using -Environment $mgEnv (Cloud=$cloudValue)"
|
||||
}
|
||||
|
||||
# Dispatch on auth method. Connect-MgGraph parameter sets are mutually
|
||||
# exclusive, so we pick exactly one.
|
||||
if($Arguments.ContainsKey('Secret') -and $Arguments.Secret) {
|
||||
if(-not $Arguments.AppId) { Write-Log "MgGraph: -AppId required with -Secret" 3; return $null }
|
||||
if(-not $Arguments.TenantId) { Write-Log "MgGraph: -TenantId required with -Secret" 3; return $null }
|
||||
$secStr = if($Arguments.Secret -is [SecureString]) { $Arguments.Secret }
|
||||
else { ConvertTo-SecureString ([string]$Arguments.Secret) -AsPlainText -Force }
|
||||
$mgArgs['ClientSecretCredential'] = [PSCredential]::new($Arguments.AppId, $secStr)
|
||||
# ClientId + TenantId are conveyed via the credential here; remove the
|
||||
# standalone entries so we don't conflict with the credential parameter set.
|
||||
$mgArgs.Remove('ClientId') | Out-Null
|
||||
}
|
||||
elseif($Arguments.ContainsKey('Certificate') -and $Arguments.Certificate) {
|
||||
if($Arguments.Certificate -is [System.Security.Cryptography.X509Certificates.X509Certificate2]) {
|
||||
$mgArgs['Certificate'] = $Arguments.Certificate
|
||||
}
|
||||
else {
|
||||
# Treat as thumbprint string
|
||||
$mgArgs['CertificateThumbprint'] = [string]$Arguments.Certificate
|
||||
}
|
||||
}
|
||||
elseif($Arguments.ContainsKey('CertificatePath') -and $Arguments.CertificatePath) {
|
||||
$cert = Get-PfxCertificate -FilePath $Arguments.CertificatePath -Password $Arguments.CertificatePassword -ErrorAction Stop
|
||||
$mgArgs['Certificate'] = $cert
|
||||
}
|
||||
elseif($Arguments.ContainsKey('Token') -and $Arguments.Token) {
|
||||
$tokStr = if($Arguments.Token -is [SecureString]) { $Arguments.Token }
|
||||
else { ConvertTo-SecureString ([string]$Arguments.Token) -AsPlainText -Force }
|
||||
$mgArgs['AccessToken'] = $tokStr
|
||||
}
|
||||
elseif($Arguments.ContainsKey('ManagedIdentity') -and $Arguments.ManagedIdentity) {
|
||||
$mgArgs['Identity'] = $true
|
||||
# For user-assigned managed identity, the user can pass a specific client id.
|
||||
if($Arguments.ManagedIdentityClientId) { $mgArgs['ClientId'] = $Arguments.ManagedIdentityClientId }
|
||||
}
|
||||
elseif($Arguments.ContainsKey('DeviceCode') -and $Arguments.DeviceCode) {
|
||||
# Microsoft.Graph.Authentication 2.x exposes device code as the
|
||||
# -UseDeviceCode switch on Connect-MgGraph. Emits the code and
|
||||
# verification URL to the console and blocks until the user
|
||||
# completes auth in a browser on any device.
|
||||
$mgArgs['UseDeviceCode'] = $true
|
||||
if($Arguments.Scopes) { $mgArgs['Scopes'] = $Arguments.Scopes }
|
||||
}
|
||||
else {
|
||||
# Interactive. Default scopes match what the rest of the app uses; callers
|
||||
# can override via $Arguments.Scopes.
|
||||
if($Arguments.Scopes) { $mgArgs['Scopes'] = $Arguments.Scopes }
|
||||
}
|
||||
|
||||
$authModeLog = if($mgArgs.ContainsKey('ClientSecretCredential')) { 'client secret' }
|
||||
elseif($mgArgs.ContainsKey('Certificate')) { 'certificate' }
|
||||
elseif($mgArgs.ContainsKey('CertificateThumbprint')) { 'certificate (thumbprint)' }
|
||||
elseif($mgArgs.ContainsKey('AccessToken')) { 'BYO token' }
|
||||
elseif($mgArgs.ContainsKey('Identity')) { 'managed identity' }
|
||||
else { 'interactive (browser)' }
|
||||
Write-Log "Calling Connect-MgGraph (mode: $authModeLog)..."
|
||||
|
||||
try {
|
||||
# Wipe any stale cached token from a prior session before the new auth.
|
||||
[AuthenticationMgGraph]::ClearTokenCache()
|
||||
|
||||
Connect-MgGraph @mgArgs -ErrorAction Stop | Out-Null
|
||||
Write-Log "Connect-MgGraph completed successfully"
|
||||
}
|
||||
catch {
|
||||
Write-LogError "Connect-MgGraph failed" $_.Exception
|
||||
return $null
|
||||
}
|
||||
|
||||
# Verify Get-MgContext returns a session. If it does, we're signed in —
|
||||
# even if we can't pull a raw bearer token out of the SDK. Invoke-MSGraphAPI
|
||||
# has an SDK-routed fallback for that case (uses Invoke-MgGraphRequest, which
|
||||
# the SDK auths internally with its own in-memory cache).
|
||||
$ctx = $null
|
||||
try { $ctx = Get-MgContext -ErrorAction SilentlyContinue } catch { }
|
||||
if(-not $ctx) {
|
||||
Write-Log "Connect-MgGraph completed but Get-MgContext returned nothing. Treating as failed auth." 3
|
||||
try { Disconnect-MgGraph -ErrorAction SilentlyContinue | Out-Null } catch { }
|
||||
[AuthenticationMgGraph]::ClearTokenCache()
|
||||
return $null
|
||||
}
|
||||
$verifyToken = $this.GetAccessToken(0, "https://$($cloudEntry.GraphHost)")
|
||||
if($verifyToken) {
|
||||
Write-LogDebug "MgGraph session verified - token extracted ($($verifyToken.Length) chars)"
|
||||
}
|
||||
else {
|
||||
# SDK v2 keeps tokens opaque by design. Invoke-MSGraphAPI has a routed
|
||||
# fallback that uses Invoke-MgGraphRequest (the SDK auths internally),
|
||||
# so this is normal — debug-level only.
|
||||
Write-LogDebug "MgGraph: session valid (Get-MgContext: tenant=$($ctx.TenantId)) but raw bearer not extractable. Graph calls route via Invoke-MgGraphRequest."
|
||||
}
|
||||
|
||||
# Realign the active auth provider so subsequent Invoke-MSGraphAPI calls route
|
||||
# here. Symmetric to the same logic in MSAL's Add-MSALTokenInfo.
|
||||
if(Get-Command Set-ActiveAuthProvider -ErrorAction SilentlyContinue) {
|
||||
$cur = Get-AuthProvider
|
||||
if($cur -and $cur.Id -ne $this.Id) {
|
||||
Write-Log "Active auth provider auto-switched from '$($cur.Id)' to '$($this.Id)' because MgGraph authentication succeeded"
|
||||
Set-ActiveAuthProvider -Id $this.Id
|
||||
}
|
||||
}
|
||||
|
||||
# Phase 3: persist per-tenant cloud memory. Prefer the Cloud arg the caller
|
||||
# asked for; fall back to mapping Get-MgContext.Environment back to a Cloud
|
||||
# value (the SDK's -Environment values match ours 1:1 via Clouds[].MgEnvironment).
|
||||
# Declared before the try so it's always in scope for Register-AuthToken below.
|
||||
$persistCloud = if($cloudValue) { $cloudValue } else { $null }
|
||||
try {
|
||||
if(-not $persistCloud -and $ctx -and $ctx.Environment) {
|
||||
$match = $script:Clouds | Where-Object MgEnvironment -eq $ctx.Environment | Select-Object -First 1
|
||||
if($match) { $persistCloud = $match.Value }
|
||||
}
|
||||
if(-not $persistCloud) { $persistCloud = Get-DefaultCloud }
|
||||
if($persistCloud -and $ctx.TenantId) {
|
||||
Save-TenantCloud -TenantId $ctx.TenantId -Cloud $persistCloud
|
||||
Save-SettingStoreValue "" "LastLoggedOnCloud" $persistCloud
|
||||
}
|
||||
}
|
||||
catch {
|
||||
Write-LogDebug "Phase 3 MgGraph cloud memory write failed: $($_.Exception.Message)"
|
||||
}
|
||||
|
||||
# Register with the central token registry (single-session invariant: drop
|
||||
# any prior entry first so repeated Connect never accumulates entries).
|
||||
# The registry fires AuthenticatedNewToken with the canonical [IMAuthToken]
|
||||
# - MgGraph now participates in the auth events for the first time.
|
||||
if($this.CurrentTokenId -gt 0) {
|
||||
Unregister-AuthToken -TokenId $this.CurrentTokenId
|
||||
}
|
||||
$this.CurrentTokenId = Get-NextAuthTokenId
|
||||
return (Register-AuthToken -Provider $this -TokenId $this.CurrentTokenId -Cloud $persistCloud)
|
||||
}
|
||||
|
||||
[bool] Disconnect([int]$TokenId) {
|
||||
try {
|
||||
Disconnect-MgGraph -ErrorAction Stop | Out-Null
|
||||
[AuthenticationMgGraph]::ClearTokenCache()
|
||||
if($this.CurrentTokenId -gt 0) {
|
||||
Unregister-AuthToken -TokenId $this.CurrentTokenId
|
||||
$this.CurrentTokenId = 0
|
||||
}
|
||||
return $true
|
||||
}
|
||||
catch {
|
||||
Write-LogError "Disconnect-MgGraph failed" $_.Exception
|
||||
return $false
|
||||
}
|
||||
}
|
||||
|
||||
# Re-trigger Connect-MgGraph against the current session's tenant. With a valid
|
||||
# refresh token in the cache the SDK does this silently; otherwise it prompts.
|
||||
[bool] Refresh([int]$TokenId) {
|
||||
try {
|
||||
$ctx = Get-MgContext -ErrorAction SilentlyContinue
|
||||
$mgArgs = @{ NoWelcome = $true }
|
||||
if($ctx -and $ctx.TenantId) { $mgArgs['TenantId'] = $ctx.TenantId }
|
||||
if($ctx -and $ctx.ClientId) { $mgArgs['ClientId'] = $ctx.ClientId }
|
||||
if($ctx -and $ctx.Scopes) { $mgArgs['Scopes'] = @($ctx.Scopes) }
|
||||
if($ctx -and $ctx.Environment -and $ctx.Environment -ne "Global") { $mgArgs['Environment'] = $ctx.Environment }
|
||||
Write-Log "MgGraph Refresh: re-running Connect-MgGraph (tenant: $($ctx.TenantId))"
|
||||
# Invalidate the cached bearer so the next GetAccessToken call re-extracts.
|
||||
[AuthenticationMgGraph]::ClearTokenCache()
|
||||
Connect-MgGraph @mgArgs -ErrorAction Stop | Out-Null
|
||||
return $true
|
||||
}
|
||||
catch {
|
||||
Write-LogError "MgGraph Refresh failed" $_.Exception
|
||||
return $false
|
||||
}
|
||||
}
|
||||
|
||||
static [void] ClearTokenCache() {
|
||||
[AuthenticationMgGraph]::CachedToken = $null
|
||||
[AuthenticationMgGraph]::CachedTokenExpiry = [DateTimeOffset]::MinValue
|
||||
[AuthenticationMgGraph]::CachedTokenTenantId = $null
|
||||
}
|
||||
|
||||
# Cached token + expiry to avoid hitting the SDK on every request.
|
||||
static [string]$CachedToken
|
||||
static [DateTimeOffset]$CachedTokenExpiry = [DateTimeOffset]::MinValue
|
||||
static [string]$CachedTokenTenantId
|
||||
|
||||
# The single global token id for this provider's one live session. MgGraph is
|
||||
# single-session by SDK design (one ambient Get-MgContext), so it holds exactly
|
||||
# one registry entry at a time. 0 = not registered.
|
||||
[int]$CurrentTokenId = 0
|
||||
|
||||
# Robust token extraction. Microsoft.Graph SDK v2 doesn't expose an access token
|
||||
# accessor — AuthContext.AccessToken stays null in the delegated flow. We use the
|
||||
# SDK's own HttpClient (which has its auth DelegatingHandler attached) to make a
|
||||
# cheap HEAD-style request; the handler mutates the request to add
|
||||
# "Authorization: Bearer <token>" before sending. We then read the header off the
|
||||
# request. Same mechanism the SDK itself uses internally on every Mg* cmdlet —
|
||||
# no reflection, no private APIs.
|
||||
[string] GetAccessToken([int]$TokenId, [string]$Resource) {
|
||||
try {
|
||||
# Return cached token if still valid (>5 min until expiry). The SDK refreshes
|
||||
# internally on every call, so caching at our layer avoids per-request
|
||||
# network round-trips just to "pull" a token.
|
||||
$ctxTenantId = $null
|
||||
try { $ctxTenantId = (Get-MgContext -ErrorAction SilentlyContinue).TenantId } catch { }
|
||||
|
||||
if([AuthenticationMgGraph]::CachedToken -and
|
||||
[AuthenticationMgGraph]::CachedTokenExpiry -gt [DateTimeOffset]::UtcNow.AddMinutes(5) -and
|
||||
[AuthenticationMgGraph]::CachedTokenTenantId -eq $ctxTenantId) {
|
||||
return [AuthenticationMgGraph]::CachedToken
|
||||
}
|
||||
|
||||
$sessionType = "Microsoft.Graph.PowerShell.Authentication.GraphSession" -as [type]
|
||||
if(-not $sessionType) {
|
||||
Write-LogDebug "MgGraph: GraphSession type not available; SDK not loaded?"
|
||||
return $null
|
||||
}
|
||||
$session = $sessionType::Instance
|
||||
if(-not $session) {
|
||||
Write-LogDebug "MgGraph: GraphSession.Instance is null"
|
||||
return $null
|
||||
}
|
||||
|
||||
# The SDK exposes GraphHttpClient: an HttpClient wired with auth handlers.
|
||||
$httpClient = $session.GraphHttpClient
|
||||
if(-not $httpClient) {
|
||||
Write-LogDebug "MgGraph: GraphHttpClient is null (Connect-MgGraph not run?)"
|
||||
return $null
|
||||
}
|
||||
|
||||
# Strategy A — direct AuthContext read (works on some SDK builds, fast-path).
|
||||
if($session.AuthContext -and $session.AuthContext.AccessToken) {
|
||||
$tok = [AuthenticationMgGraph]::UnprotectString($session.AuthContext.AccessToken)
|
||||
if($tok) {
|
||||
[AuthenticationMgGraph]::SaveTokenCache($tok, $ctxTenantId)
|
||||
Write-LogDebug "MgGraph: token from AuthContext.AccessToken (fast-path)"
|
||||
return $tok
|
||||
}
|
||||
}
|
||||
|
||||
# Strategy B — HttpClient sniff: make a trivial GET via the SDK's HttpClient,
|
||||
# then read the Authorization header that the DelegatingHandler attached.
|
||||
# This is the supported public contract of the SDK's auth pipeline.
|
||||
$graphResource = if($Resource) { $Resource.TrimEnd('/') } else { "https://$(Get-GraphDomain)" }
|
||||
$req = [System.Net.Http.HttpRequestMessage]::new(
|
||||
[System.Net.Http.HttpMethod]::Get,
|
||||
"$graphResource/v1.0/`$metadata")
|
||||
try {
|
||||
$task = $httpClient.SendAsync(
|
||||
$req,
|
||||
[System.Net.Http.HttpCompletionOption]::ResponseHeadersRead)
|
||||
# 30s timeout — interactive auth could be required if cache is cold.
|
||||
if(-not $task.Wait(30000)) {
|
||||
Write-LogDebug "MgGraph: HttpClient sniff timed out"
|
||||
return $null
|
||||
}
|
||||
# Drop the response (we only care about the request headers the handler
|
||||
# populated). Dispose to free the socket.
|
||||
try { $task.Result.Dispose() } catch { }
|
||||
}
|
||||
catch {
|
||||
Write-LogDebug "MgGraph: HttpClient sniff failed: $($_.Exception.Message)"
|
||||
}
|
||||
|
||||
if($req.Headers.Authorization -and
|
||||
$req.Headers.Authorization.Scheme -eq 'Bearer' -and
|
||||
$req.Headers.Authorization.Parameter) {
|
||||
$token = $req.Headers.Authorization.Parameter
|
||||
[AuthenticationMgGraph]::SaveTokenCache($token, $ctxTenantId)
|
||||
Write-LogDebug "MgGraph: token via HttpClient auth-handler sniff"
|
||||
return $token
|
||||
}
|
||||
|
||||
# Last resort — reflection probe.
|
||||
$token = [AuthenticationMgGraph]::FindTokenViaReflection($session)
|
||||
if($token) {
|
||||
[AuthenticationMgGraph]::SaveTokenCache($token, $ctxTenantId)
|
||||
Write-LogDebug "MgGraph: token via reflection"
|
||||
return $token
|
||||
}
|
||||
|
||||
$ctxJson = "<null>"
|
||||
if($session.AuthContext) {
|
||||
try {
|
||||
$ctxJson = ($session.AuthContext | Select-Object TenantId, ClientId, AppName, AuthType, @{n='AccessTokenPresent';e={[bool]$_.AccessToken}}, @{n='Scopes';e={($_.Scopes -join ', ')}} | ConvertTo-Json -Compress)
|
||||
}
|
||||
catch { $ctxJson = "<unserializable>" }
|
||||
}
|
||||
Write-Log "MgGraph: could not extract access token. AuthContext=$ctxJson" 2
|
||||
return $null
|
||||
}
|
||||
catch {
|
||||
Write-LogError "Failed to extract MgGraph access token" $_.Exception
|
||||
return $null
|
||||
}
|
||||
}
|
||||
|
||||
# Persist the freshly-acquired token + expiry. Expiry parsed from the JWT exp claim;
|
||||
# fall back to "now + 50 minutes" if parsing fails (Entra tokens default to 60 min).
|
||||
static [void] SaveTokenCache([string]$Token, [string]$TenantId) {
|
||||
[AuthenticationMgGraph]::CachedToken = $Token
|
||||
[AuthenticationMgGraph]::CachedTokenTenantId = $TenantId
|
||||
$expiry = [DateTimeOffset]::UtcNow.AddMinutes(50)
|
||||
try {
|
||||
# Decode JWT exp claim
|
||||
$jwt = Get-JWTtoken $Token
|
||||
if($jwt -and $jwt.Payload -and $jwt.Payload.exp) {
|
||||
$expiry = [DateTimeOffset]::FromUnixTimeSeconds([long]$jwt.Payload.exp)
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
[AuthenticationMgGraph]::CachedTokenExpiry = $expiry
|
||||
}
|
||||
|
||||
# Unprotect a SecureString or pass a plain string through.
|
||||
static [string] UnprotectString($Value) {
|
||||
if($null -eq $Value) { return $null }
|
||||
if($Value -is [SecureString]) {
|
||||
return [System.Net.NetworkCredential]::new("", $Value).Password
|
||||
}
|
||||
return [string]$Value
|
||||
}
|
||||
|
||||
# Walks the session object graph looking for a property/field whose name suggests it
|
||||
# holds an access token. Limited to 2 levels deep to avoid infinite recursion.
|
||||
static [string] FindTokenViaReflection($obj) {
|
||||
if($null -eq $obj) { return $null }
|
||||
try {
|
||||
foreach($prop in $obj.PSObject.Properties) {
|
||||
if($prop.Name -match 'AccessToken|BearerToken|JWT|^Token$') {
|
||||
$val = $prop.Value
|
||||
if($val) {
|
||||
$unwrapped = [AuthenticationMgGraph]::UnprotectString($val)
|
||||
if($unwrapped -and $unwrapped.Length -gt 20) { return $unwrapped }
|
||||
}
|
||||
}
|
||||
}
|
||||
foreach($prop in $obj.PSObject.Properties) {
|
||||
if($prop.Name -in 'AuthContext','InMemoryTokenCache','GraphOption','RequestContext') {
|
||||
$child = $prop.Value
|
||||
if($child) {
|
||||
foreach($childProp in $child.PSObject.Properties) {
|
||||
if($childProp.Name -match 'AccessToken|BearerToken|JWT|^Token$') {
|
||||
$val = $childProp.Value
|
||||
if($val) {
|
||||
$unwrapped = [AuthenticationMgGraph]::UnprotectString($val)
|
||||
if($unwrapped -and $unwrapped.Length -gt 20) { return $unwrapped }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
return $null
|
||||
}
|
||||
|
||||
[datetime] GetAccessTokenExpiry([int]$TokenId, [string]$Resource) {
|
||||
# SDK does not expose the expiry to consumers. We return MaxValue and rely on
|
||||
# MgGraph's internal silent refresh (it owns the cache).
|
||||
return [datetime]::MaxValue
|
||||
}
|
||||
|
||||
# Cached tenant display name to avoid hitting /organization on every refresh.
|
||||
static [hashtable]$TenantNameCache = @{}
|
||||
|
||||
[PSCustomObject] GetUserInfo([int]$TokenId) {
|
||||
try {
|
||||
$ctx = Get-MgContext -ErrorAction SilentlyContinue
|
||||
if(-not $ctx) { return $null }
|
||||
|
||||
# SDK reports AuthType as Delegated / AppOnly. Map to our taxonomy.
|
||||
$authType = switch ($ctx.AuthType) {
|
||||
"AppOnly" { "ClientCredential" }
|
||||
"Delegated" { "Interactive" }
|
||||
default { "$($ctx.AuthType)" }
|
||||
}
|
||||
|
||||
# Get-MgContext doesn't surface tenant display name. Fetch it once per
|
||||
# tenant via Invoke-MgGraphRequest /organization (the SDK handles auth);
|
||||
# cache for the rest of the session.
|
||||
$tenantName = $null
|
||||
if($ctx.TenantId) {
|
||||
if([AuthenticationMgGraph]::TenantNameCache.ContainsKey($ctx.TenantId)) {
|
||||
$tenantName = [AuthenticationMgGraph]::TenantNameCache[$ctx.TenantId]
|
||||
}
|
||||
else {
|
||||
try {
|
||||
$org = Invoke-MgGraphRequest -Method GET -Uri "https://$(Get-GraphDomain)/v1.0/organization" -OutputType PSObject -ErrorAction Stop
|
||||
if($org -and $org.value -and $org.value.Count -gt 0 -and $org.value[0].displayName) {
|
||||
$tenantName = $org.value[0].displayName
|
||||
[AuthenticationMgGraph]::TenantNameCache[$ctx.TenantId] = $tenantName
|
||||
}
|
||||
}
|
||||
catch {
|
||||
Write-LogDebug "MgGraph GetUserInfo: /organization fetch failed ($($_.Exception.Message)); tenant display name will be unknown"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# Expiry comes from the JWT exp claim we parsed into CachedTokenExpiry.
|
||||
# If no token has been minted yet this session, trigger one — cheap when
|
||||
# the SDK's in-memory cache is warm.
|
||||
$expiresOn = $null
|
||||
if([AuthenticationMgGraph]::CachedTokenExpiry -gt [DateTimeOffset]::MinValue) {
|
||||
$expiresOn = [AuthenticationMgGraph]::CachedTokenExpiry.LocalDateTime
|
||||
}
|
||||
else {
|
||||
try {
|
||||
[void]$this.GetAccessToken(0, "https://$(Get-GraphDomain)")
|
||||
if([AuthenticationMgGraph]::CachedTokenExpiry -gt [DateTimeOffset]::MinValue) {
|
||||
$expiresOn = [AuthenticationMgGraph]::CachedTokenExpiry.LocalDateTime
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
|
||||
return [PSCustomObject]@{
|
||||
Provider = $this.Id
|
||||
DisplayName = $ctx.Account
|
||||
UPN = $ctx.Account
|
||||
UserId = $null # Not surfaced by Get-MgContext
|
||||
TenantId = $ctx.TenantId
|
||||
TenantName = $tenantName
|
||||
AppId = $ctx.ClientId
|
||||
AppName = $ctx.AppName
|
||||
AuthType = $authType
|
||||
ExpiresOn = $expiresOn
|
||||
}
|
||||
}
|
||||
catch {
|
||||
return $null
|
||||
}
|
||||
}
|
||||
|
||||
[PSCustomObject[]] GetCachedAccounts() {
|
||||
# Delegate to a module function — PS class method bodies are parsed strictly
|
||||
# (type references like [Microsoft.Identity.Client.PublicClientApplicationBuilder]
|
||||
# have to resolve at PARSE time, before MSAL DLLs are loaded). Module
|
||||
# functions are late-bound and tolerate this.
|
||||
$rows = @()
|
||||
try {
|
||||
$rows = @(Get-MgGraphCachedMsalAccounts -ProviderId $this.Id)
|
||||
}
|
||||
catch {
|
||||
Write-LogDebug "MgGraph GetCachedAccounts failed: $($_.Exception.Message)"
|
||||
}
|
||||
return [PSCustomObject[]]$rows
|
||||
}
|
||||
|
||||
[PSCustomObject[]] GetAvailableTenants([int]$TokenId) {
|
||||
# SDK does not surface available tenants. Tenant switching means
|
||||
# Disconnect + Connect with -TenantId, which the UI can offer via a manual
|
||||
# tenant id entry (Phase 3 UI concern).
|
||||
return [PSCustomObject[]]@()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user