private/Get-MsalEssentialsApplicationToken.ps1

<#
    .SYNOPSIS
        Gets an access token for the application itself (application
        permissions) with a certificate or a client secret.
 
    .DESCRIPTION
        Private helper used by Connect-MsalEssentials. Builds an MSAL
        confidential client and requests a token with the client credentials
        flow. No user, no browser and no redirect URI are
        involved. Needs only the Microsoft.Identity.Client assembly to be
        loaded, so it can also be used on its own.
 
        The token carries the application permissions (app roles) that were
        granted to the app and consented by an administrator.
 
        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, AadstsCode, ErrorCode,
                                   CorrelationId, Claims, TenantId, ClientId
 
        Categories: AppConfiguration, Unauthorized, ConditionalAccess,
        Authentication.
 
    .PARAMETER TenantId
        The directory (tenant) ID or verified domain to authenticate against.
        The client credentials flow needs a specific tenant. 'common',
        'organizations' and 'consumers' do not work.
 
    .PARAMETER ClientId
        The application (client) ID registered in Entra ID.
 
    .PARAMETER Certificate
        A certificate with a private key. Its public part must be uploaded on
        the app registration (Certificates & secrets > Certificates).
 
    .PARAMETER ClientSecret
        The client secret value as a SecureString. It is converted to plain
        text only for the moment MSAL needs it. .NET strings can't be wiped,
        so a copy may stay in memory until garbage collection.
 
    .PARAMETER Scopes
        Exactly one scope that ends with '/.default', for example
        'https://graph.microsoft.com/.default'. Application permissions are
        granted on the app registration, not requested per call.
 
    .PARAMETER ApplicationReference
        Receives the MSAL client, so the caller can renew the token later.
 
    .EXAMPLE
        Get-MsalEssentialsApplicationToken -TenantId $tenantId -ClientId $clientId -Certificate $certificate
        Gets an app-only Microsoft Graph token with a certificate.
 
    .EXAMPLE
        Get-MsalEssentialsApplicationToken -TenantId $tenantId -ClientId $clientId -ClientSecret $secureSecret -Scopes 'https://vault.azure.net/.default'
        Gets an app-only token for Azure Key Vault with a client secret.
#>


function Get-MsalEssentialsApplicationToken {
    [CmdletBinding(DefaultParameterSetName = 'Certificate')]
    param (
        [Parameter(Mandatory)]
        [string]
        $TenantId,

        [Parameter(Mandatory)]
        [string]
        $ClientId,

        [Parameter(Mandatory, ParameterSetName = 'Certificate')]
        [System.Security.Cryptography.X509Certificates.X509Certificate2]
        $Certificate,

        [Parameter(Mandatory, ParameterSetName = 'Secret')]
        [System.Security.SecureString]
        $ClientSecret,

        [Parameter()]
        [ValidateCount(1, 1)]
        [ValidatePattern('/\.default$')]
        [string[]]
        $Scopes = @('https://graph.microsoft.com/.default'),

        [Parameter()]
        [ref]
        $ApplicationReference
    )

    try {
        #region Build the MSAL client

        $clientBuilder = [Microsoft.Identity.Client.ConfidentialClientApplicationBuilder]::Create(
            $ClientId
        ).WithAuthority(
            ('https://login.microsoftonline.com/{0}' -f $TenantId)
        )

        if ($PSCmdlet.ParameterSetName -eq 'Certificate') {
            $clientBuilder = $clientBuilder.WithCertificate($Certificate)
        }
        else {
            # MSAL only accepts the secret as a string. Keep the plain text
            # only as long as the builder needs it, and wipe the unmanaged copy.
            $secretPointer = [System.IntPtr]::Zero
            try {
                $secretPointer = [System.Runtime.InteropServices.Marshal]::SecureStringToGlobalAllocUnicode($ClientSecret)
                $clientBuilder = $clientBuilder.WithClientSecret(
                    [System.Runtime.InteropServices.Marshal]::PtrToStringUni($secretPointer)
                )
            }
            finally {
                if ($secretPointer -ne [System.IntPtr]::Zero) {
                    [System.Runtime.InteropServices.Marshal]::ZeroFreeGlobalAllocUnicode($secretPointer)
                }
            }
        }

        $confidentialClientApplication = $clientBuilder.Build()

        #endregion Build the MSAL client

        # Hand the client back so the token can be renewed later. MSAL keeps
        # app tokens in its in-memory cache, so the same instance is needed.
        if ($ApplicationReference) {
            $ApplicationReference.Value = $confidentialClientApplication
        }

        Write-Verbose -Message (
            'Requesting an application token for ''{0}'' (tenant ''{1}'', client ''{2}'') with a {3}.' -f
                $Scopes[0],
                $TenantId,
                $ClientId,
                $PSCmdlet.ParameterSetName.ToLowerInvariant()
        )

        # 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.
        return $confidentialClientApplication.AcquireTokenForClient(
            [string[]]$Scopes
        ).ExecuteAsync().GetAwaiter().GetResult()
    }
    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 }
        $correlationId = if ($isServiceException) { $caughtException.CorrelationId } else { $null }
        $claims        = if ($isServiceException) { $caughtException.Claims }        else { $null }

        $aadstsCode = $null
        if ($caughtException.Message -match 'AADSTS(?<code>\d+)') {
            $aadstsCode = $Matches['code']
        }

        $publicClientHint = 'The app registration is set up as a public client. Entra admin center > App registrations > your app > Authentication > Settings: set "Allow public client flows" to No.'
        $secretHint       = 'Create a new secret under Certificates & secrets and use its Value (shown only once), not the Secret ID.'

        # https://learn.microsoft.com/en-us/entra/identity-platform/reference-error-codes
        $knownAadstsErrors = @{
            '700016'  = @{ Category = 'AppConfiguration';  Hint = 'The application (client) ID was not found in this tenant. Check -ClientId and -TenantId, and that the app is registered in this tenant.' }
            '90002'   = @{ Category = 'AppConfiguration';  Hint = 'The tenant was not found. Check -TenantId. The client credentials flow needs a tenant ID or domain, not "common" or "organizations".' }
            '900023'  = @{ Category = 'AppConfiguration';  Hint = 'The tenant identifier is not valid. Check -TenantId.' }
            '70011'   = @{ Category = 'AppConfiguration';  Hint = 'The requested scope is not valid. Use the resource with "/.default", for example https://graph.microsoft.com/.default.' }
            '500011'  = @{ Category = 'AppConfiguration';  Hint = 'The resource in -Scopes was not found in this tenant. Check the resource part of the scope.' }
            '700025'  = @{ Category = 'AppConfiguration';  Hint = $publicClientHint }
            '7000218' = @{ Category = 'AppConfiguration';  Hint = $publicClientHint }
            '7000215' = @{ Category = 'Unauthorized';      Hint = ('The client secret is not valid. {0}' -f $secretHint) }
            '7000222' = @{ Category = 'Unauthorized';      Hint = ('The client secret has expired. {0}' -f $secretHint) }
            '700027'  = @{ Category = 'Unauthorized';      Hint = 'The certificate signature was rejected. Upload the public part (.cer) of exactly this certificate on the app registration (Certificates & secrets > Certificates), and check that it has not expired.' }
            '700024'  = @{ Category = 'Unauthorized';      Hint = 'The certificate assertion is not within its valid time. Check the clock of this computer and the validity period of the certificate.' }
            '700023'  = @{ Category = 'Unauthorized';      Hint = 'The certificate does not match the application. Check -ClientId and the certificate uploaded on the app registration.' }
            '53003'   = @{ Category = 'ConditionalAccess'; Hint = 'Access was blocked by a Conditional Access policy for workload identities (for example location). Ask your administrator which policy applies.' }
        }

        $errorCategoryMap = @{
            AppConfiguration  = [System.Management.Automation.ErrorCategory]::InvalidOperation
            Unauthorized      = [System.Management.Automation.ErrorCategory]::PermissionDenied
            ConditionalAccess = [System.Management.Automation.ErrorCategory]::SecurityError
            Authentication    = [System.Management.Automation.ErrorCategory]::AuthenticationError
        }

        $category = 'Authentication'
        $summary  = ($caughtException.Message -split '\r?\n')[0]
        $hint     = $null

        if ($aadstsCode -and $knownAadstsErrors.ContainsKey($aadstsCode)) {
            $category = $knownAadstsErrors[$aadstsCode].Category
            $hint     = $knownAadstsErrors[$aadstsCode].Hint
        }
        elseif ($claims) {
            $category = 'ConditionalAccess'
            $hint     = 'A Conditional Access policy returned a claims challenge. Ask your administrator which policy applies.'
        }
        elseif ($errorCode -in 'invalid_client', 'unauthorized_client', 'access_denied') {
            $category = 'Unauthorized'
            $hint     = 'The app was not accepted. Check the client secret or certificate, and the app registration.'
        }
        elseif ($isServiceException -and $caughtException.StatusCode -in 401, 403) {
            $category = 'Unauthorized'
            $hint     = 'The service rejected the request as unauthorized. Check the app registration, granted permissions and admin consent.'
        }

        $detailLines = New-Object -TypeName 'System.Collections.Generic.List[string]'
        $detailLines.Add(('Token request failed [{0}]: {1}' -f $category, $summary))
        if ($hint) {
            $detailLines.Add(('Hint: {0}' -f $hint))
        }

        $technicalDetails = New-Object -TypeName 'System.Collections.Generic.List[string]'
        if ($aadstsCode)    { $technicalDetails.Add(('AADSTS{0}' -f $aadstsCode)) }
        if ($errorCode)     { $technicalDetails.Add(('ErrorCode: {0}' -f $errorCode)) }
        if ($correlationId) { $technicalDetails.Add(('CorrelationId: {0}' -f $correlationId)) }
        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
                AadstsCode    = $aadstsCode
                ErrorCode     = $errorCode
                CorrelationId = $correlationId
                Claims        = $claims
                TenantId      = $TenantId
                ClientId      = $ClientId
            }
        )
        $errorRecord.ErrorDetails = [System.Management.Automation.ErrorDetails]::new(($detailLines -join [System.Environment]::NewLine))

        $PSCmdlet.ThrowTerminatingError($errorRecord)
        #endregion exception handling
    }
}