public/Get-MsalEssentialsToken.ps1

<#
    .SYNOPSIS
        Returns the current connection with a valid access token, renewing the
        token silently if it expires soon.
 
    .DESCRIPTION
        Looks at the connection stored by Connect-MsalEssentials:
 
        1. Token expires in more than -RefreshBeforeExpiryMinutes (default 15):
           returns the stored token unchanged.
        2. Token expires in -RefreshBeforeExpiryMinutes or less: gets a new
           token silently, without opening a browser, using the MSAL client
           and refresh token kept from the original sign-in.
 
        This function never signs in. It does not open a browser, and it does
        not take a tenant, client or credentials. Signing in is the job of
        Connect-MsalEssentials, whatever the authentication method. If this
        function cannot return a valid token it fails with a terminating
        error that tells you to run Connect-MsalEssentials again:
 
            MsalEssentials.NotConnected no connection exists yet
            MsalEssentials.ReconnectRequired the token expired and silent
                                               renewal needs a new sign-in
                                               (refresh token expired or
                                               revoked, Conditional Access
                                               asks for a new sign-in, ...)
            MsalEssentials.RenewalNotSupported the connection type cannot be
                                               renewed by this function yet
            (any other error from a failed silent renewal is passed through
            unchanged)
 
        If renewal fails but the current token has not expired yet, the
        current token is still returned and a warning explains why renewal
        failed. The next call tries to renew again.
 
        The returned object is the same one Connect-MsalEssentials -PassThru
        returns: AuthenticationType, TenantId, ClientId, Username, Scopes,
        ExpiresOn and AccessToken, plus a CreateAuthorizationHeader() method.
        The access token is hidden from the default console display but is
        still a credential: Format-List *, ConvertTo-Json and Export-Clixml
        will show it.
 
    .PARAMETER RefreshBeforeExpiryMinutes
        Renew the token when it expires within this many minutes. Defaults
        to 15. Use 0 to renew only once the token has expired.
 
    .EXAMPLE
        Connect-MsalEssentials -TenantId $tenantId -ClientID $clientId
        $connection = Get-MsalEssentialsToken
        Invoke-RestMethod -Uri 'https://graph.microsoft.com/v1.0/me' -Headers @{ Authorization = $connection.CreateAuthorizationHeader() }
        Signs in once, then gets the token before each request; it is renewed silently when it expires soon.
 
    .EXAMPLE
        (Get-MsalEssentialsToken -RefreshBeforeExpiryMinutes 30).AccessToken
        Returns only the access token string, renewing it if it expires within 30 minutes.
 
    .EXAMPLE
        try {
            $connection = Get-MsalEssentialsToken -ErrorAction 'Stop'
        }
        catch {
            Connect-MsalEssentials -TenantId $tenantId -ClientID $clientId -ErrorAction 'Stop'
            $connection = Get-MsalEssentialsToken -ErrorAction 'Stop'
        }
        Lets the caller decide how to sign in again when there is no usable token.
#>


function Get-MsalEssentialsToken {
    [CmdletBinding()]
    [OutputType('MsalEssentials.Connection')]
    param (
        [Parameter()]
        [ValidateRange(0, 1440)]
        [int]
        $RefreshBeforeExpiryMinutes = 15
    )

    $session = Get-Variable -Name 'MsalEssentialsSession' -Scope 'Script' -ValueOnly -ErrorAction 'SilentlyContinue'

    if (-not $session -or -not $session.AuthenticationResult) {
        $notConnectedRecord = [System.Management.Automation.ErrorRecord]::new(
            [System.InvalidOperationException]::new('Not connected. Run Connect-MsalEssentials first.'),
            'MsalEssentials.NotConnected',
            [System.Management.Automation.ErrorCategory]::InvalidOperation,
            $null
        )
        $PSCmdlet.ThrowTerminatingError($notConnectedRecord)
    }

    $remainingTime = $session.AuthenticationResult.ExpiresOn - [System.DateTimeOffset]::UtcNow

    if ($remainingTime.TotalMinutes -le $RefreshBeforeExpiryMinutes) {
        Write-Verbose -Message ('Token expires in {0:N1} minutes (limit {1}), renewing silently.' -f $remainingTime.TotalMinutes, $RefreshBeforeExpiryMinutes)

        $renewalError = $null

        switch ($session.AuthenticationType) {
            'Delegated' {
                try {
                    # AcquireTokenSilent needs the scopes and the account from the original sign-in, which are stored in the session.
                    $session.AuthenticationResult = $session.PublicClientApplication.AcquireTokenSilent(
                        [string[]]$session.RequestedScopes,
                        $session.AuthenticationResult.Account
                    ).WithForceRefresh(
                        $true 
                        # ForceRefresh is needed to get a new token.
                        # Otherwise MSAL only returns a token if the expiration is less than 5 minutes away, which is not enough for this function.
                        # See https://learn.microsoft.com/en-us/dotnet/api/microsoft.identity.client.clientapplicationbase.acquiretokensilent?view=msal-dotnet-latest#remarks
                    ).ExecuteAsync().GetAwaiter().GetResult()
                }
                catch {
                    $renewalError = $_
                }
            }

            # Future authentication types are renewed here, for example:
            # 'AppCertificate' -> call AcquireTokenForClient again
            # 'ManagedIdentity' -> request a new token from the endpoint
            # Renewal must never start an interactive sign-in.
            default {
                $renewalError = [System.Management.Automation.ErrorRecord]::new(
                    [System.NotSupportedException]::new(('Renewing a "{0}" connection is not supported.' -f $session.AuthenticationType)),
                    'MsalEssentials.RenewalNotSupported',
                    [System.Management.Automation.ErrorCategory]::NotImplemented,
                    $null
                )
            }
        }

        if ($renewalError) {
            # PowerShell wraps exceptions from directly-called .NET methods in a
            # MethodInvocationException, so look through the whole chain for the
            # MSAL exception that says a new sign-in is needed.
            $uiRequiredException = $null
            $innermostException  = $renewalError.Exception
            $currentException    = $renewalError.Exception
            while ($currentException) {
                if (-not $uiRequiredException -and $currentException -is [Microsoft.Identity.Client.MsalUiRequiredException]) {
                    $uiRequiredException = $currentException
                }
                $innermostException = $currentException
                $currentException   = $currentException.InnerException
            }

            $reasonException = if ($uiRequiredException) { $uiRequiredException } else { $innermostException }
            $reason          = ($reasonException.Message -split '\r?\n')[0]

            if ($remainingTime.TotalSeconds -gt 0) {
                # The current token still works, so keep the caller going.
                Write-Warning -Message (
                    'The access token could not be renewed and expires in {0:N1} minutes (reason: {1}). Run Connect-MsalEssentials to sign in again.' -f
                        $remainingTime.TotalMinutes,
                        $reason
                )
            }
            elseif ($uiRequiredException) {
                $reconnectRecord = [System.Management.Automation.ErrorRecord]::new(
                    [System.InvalidOperationException]::new(
                        ('The access token has expired and could not be renewed silently (reason: {0}). Run Connect-MsalEssentials to sign in again.' -f $reason),
                        $uiRequiredException
                    ),
                    'MsalEssentials.ReconnectRequired',
                    [System.Management.Automation.ErrorCategory]::AuthenticationError,
                    $null
                )
                $PSCmdlet.ThrowTerminatingError($reconnectRecord)
            }
            else {
                $PSCmdlet.ThrowTerminatingError($renewalError)
            }
        }
    }

    ConvertTo-MsalEssentialsConnection -Session $session
}