private/Get-MsalEssentialsManagedIdentityToken.ps1
|
<#
.SYNOPSIS Gets an access token for the managed identity of the Azure resource this code runs on (system-assigned or user-assigned). .DESCRIPTION Private helper used by Connect-MsalEssentials. Builds an MSAL managed identity client and requests a token from the managed identity endpoint of the host. No tenant, no secret, no certificate, no browser and no redirect URI are involved: the host (Azure VM, App Service, Azure Functions, Azure Arc, ...) proves who it is. Needs only the Microsoft.Identity.Client assembly to be loaded, so it can also be used on its own. MSAL detects the endpoint itself (IMDS on a VM, the IDENTITY_ENDPOINT environment variable on App Service and Functions, the Arc agent on an Arc-enabled server, ...). On a computer without a managed identity, such as a laptop, the request fails with MsalEssentials.ManagedIdentityUnavailable. The token carries the permissions granted to the managed identity: Azure RBAC roles, or application permissions (app roles) for APIs such as Microsoft Graph. Failures are turned into a terminating error with a category, a hint and the technical details: FullyQualifiedErrorId MsalEssentials.<Category> ErrorDetails.Message summary, hint and technical details TargetObject Category, ErrorCode, StatusCode, ManagedIdentitySource, ClientId Categories: ManagedIdentityUnavailable, AppConfiguration, Unauthorized, Authentication. .PARAMETER ClientId The client ID of a user-assigned managed identity. Omit it to use the system-assigned managed identity of the resource. Find it in the Azure portal > Managed Identities > your identity > Overview > Client ID. The identity must be assigned to the resource this code runs on. .PARAMETER Scopes Exactly one scope that ends with '/.default', for example 'https://graph.microsoft.com/.default'. MSAL sends only the resource part (without '/.default') to the endpoint. .PARAMETER ApplicationReference Receives the MSAL client, so the caller can renew the token later. .EXAMPLE Get-MsalEssentialsManagedIdentityToken Gets a Microsoft Graph token for the system-assigned managed identity. .EXAMPLE Get-MsalEssentialsManagedIdentityToken -ClientId $userAssignedClientId -Scopes 'https://vault.azure.net/.default' Gets an Azure Key Vault token for a user-assigned managed identity. #> function Get-MsalEssentialsManagedIdentityToken { [CmdletBinding()] param ( [Parameter()] [string] $ClientId, [Parameter()] [ValidateCount(1, 1)] [ValidatePattern('/\.default$')] [string[]] $Scopes = @('https://graph.microsoft.com/.default'), [Parameter()] [ref] $ApplicationReference ) $identityDescription = if ($ClientId) { 'user-assigned managed identity ''{0}''' -f $ClientId } else { 'system-assigned managed identity' } try { #region Build the MSAL client $managedIdentityId = if ($ClientId) { [Microsoft.Identity.Client.AppConfig.ManagedIdentityId]::WithUserAssignedClientId($ClientId) } else { [Microsoft.Identity.Client.AppConfig.ManagedIdentityId]::SystemAssigned } $managedIdentityApplication = [Microsoft.Identity.Client.ManagedIdentityApplicationBuilder]::Create( $managedIdentityId ).Build() #endregion Build the MSAL client # Hand the client back so the token can be renewed later. MSAL keeps # managed identity tokens in its in-memory cache, so the same # instance is needed. if ($ApplicationReference) { $ApplicationReference.Value = $managedIdentityApplication } Write-Verbose -Message ('Requesting a token for ''{0}'' with the {1}.' -f $Scopes[0], $identityDescription) # Returns the full MSAL AuthenticationResult. Don't print it to the # console: PowerShell's formatter walks its deep, self-referencing # properties and can crash the process with a StackOverflowException. # Wait-MsalEssentialsTask keeps the wait from deadlocking on a UI thread (WPF, WinForms). return Wait-MsalEssentialsTask -ScriptBlock { # MSAL takes a resource here, not a scope, and removes a trailing # '/.default' itself, so the scope can be passed on unchanged. $managedIdentityApplication.AcquireTokenForManagedIdentity( $Scopes[0] ).ExecuteAsync() } } catch { #region exception handling # PowerShell wraps exceptions from directly-called .NET methods, so # walk the chain and prefer the MSAL exception. $exceptionChain = New-Object -TypeName 'System.Collections.Generic.List[System.Exception]' $currentException = $_.Exception while ($currentException) { $exceptionChain.Add($currentException) $currentException = $currentException.InnerException } $caughtException = $exceptionChain | Where-Object -FilterScript { $_ -is [Microsoft.Identity.Client.MsalException] } | Select-Object -First 1 if (-not $caughtException) { $caughtException = $exceptionChain[$exceptionChain.Count - 1] } Write-Verbose -Message ('{0}: {1}' -f $caughtException.GetType().FullName, $caughtException.Message) $isMsalException = $caughtException -is [Microsoft.Identity.Client.MsalException] $isServiceException = $caughtException -is [Microsoft.Identity.Client.MsalServiceException] $errorCode = if ($isMsalException) { $caughtException.ErrorCode } else { $null } $statusCode = if ($isServiceException -and $caughtException.StatusCode) { $caughtException.StatusCode } else { $null } # For IMDS (Azure VMs) MSAL leaves StatusCode at 0 and only writes the # HTTP status into the message, for example "Status: BadRequest". if (-not $statusCode -and $caughtException.Message -match '(?m)^Status:\s*(?<status>[A-Za-z]+)\s*$') { $parsedStatus = $Matches['status'] -as [System.Net.HttpStatusCode] if ($null -ne $parsedStatus) { $statusCode = [int]$parsedStatus } } # MSAL names the endpoint it used (Imds, AppService, AzureArc, ...), # which tells the user where to look. $managedIdentitySource = $null if ($isMsalException -and $caughtException.AdditionalExceptionData -and $caughtException.AdditionalExceptionData.ContainsKey('ManagedIdentitySource')) { $managedIdentitySource = $caughtException.AdditionalExceptionData['ManagedIdentitySource'] } $unavailableHint = 'No managed identity endpoint was reachable. Managed identities only exist on Azure resources (VM, App Service, Functions, Azure Arc, ...). Run this on such a resource and turn its managed identity on, or use -Certificate or -ClientSecret on other computers.' $category = 'Authentication' $summary = ($caughtException.Message -split '\r?\n')[0] $hint = $null if ($errorCode -in 'managed_identity_unreachable_network', 'managed_identity_all_sources_unavailable') { $category = 'ManagedIdentityUnavailable' $hint = $unavailableHint } elseif ($statusCode -eq 400 -and $ClientId) { # IMDS ("Identity not found") and App Service answer 400 when the # requested identity is not assigned to the resource. $category = 'AppConfiguration' $hint = 'The user-assigned managed identity was not found on this resource. Check -ClientId (the Client ID of the identity, not its Object ID) and that the identity is assigned to this resource (Settings > Identity > User assigned).' } elseif ($statusCode -eq 400) { $category = 'AppConfiguration' $hint = 'The managed identity endpoint rejected the request. Check that the system-assigned identity is turned on (Settings > Identity > System assigned) and that the resource in -Scopes is valid.' } elseif ($statusCode -in 401, 403) { $category = 'Unauthorized' $hint = 'The managed identity endpoint rejected the request as unauthorized. On Azure Arc, the account running this needs local admin rights (Windows: Administrators or "Hybrid Agent Extension Applications"; Linux: the himds group).' } $errorCategoryMap = @{ ManagedIdentityUnavailable = [System.Management.Automation.ErrorCategory]::ResourceUnavailable AppConfiguration = [System.Management.Automation.ErrorCategory]::InvalidOperation Unauthorized = [System.Management.Automation.ErrorCategory]::PermissionDenied Authentication = [System.Management.Automation.ErrorCategory]::AuthenticationError } $detailLines = New-Object -TypeName 'System.Collections.Generic.List[string]' $detailLines.Add(('Token request for the {0} failed [{1}]: {2}' -f $identityDescription, $category, $summary)) if ($hint) { $detailLines.Add(('Hint: {0}' -f $hint)) } $technicalDetails = New-Object -TypeName 'System.Collections.Generic.List[string]' if ($errorCode) { $technicalDetails.Add(('ErrorCode: {0}' -f $errorCode)) } if ($statusCode) { $technicalDetails.Add(('StatusCode: {0}' -f $statusCode)) } if ($managedIdentitySource) { $technicalDetails.Add(('Source: {0}' -f $managedIdentitySource)) } if ($technicalDetails.Count -gt 0) { $detailLines.Add(('Details: {0}' -f ($technicalDetails -join ' | '))) } $errorRecord = [System.Management.Automation.ErrorRecord]::new( $caughtException, ('MsalEssentials.{0}' -f $category), $errorCategoryMap[$category], [pscustomobject]@{ Category = $category ErrorCode = $errorCode StatusCode = $statusCode ManagedIdentitySource = $managedIdentitySource ClientId = $ClientId } ) $errorRecord.ErrorDetails = [System.Management.Automation.ErrorDetails]::new(($detailLines -join [System.Environment]::NewLine)) $PSCmdlet.ThrowTerminatingError($errorRecord) #endregion exception handling } } |