Providers/Entra/Public/Get-EntraAccessToken.ps1

function Get-EntraAccessToken {
    <#
    .SYNOPSIS
        Exchanges a signed client assertion for a Graph access token

    .DESCRIPTION
        Performs the client credentials grant against the tenant's v2.0 token endpoint,
        authenticating with a certificate rather than a secret.

        The token is cached on the connection and reused until it is within two minutes of
        expiry, then silently renewed. Two minutes rather than nothing because a token that
        passes the expiry check and then expires in flight produces a 401 on an arbitrary
        call in the middle of a seed run, which is a confusing way to discover a clock is
        slightly off.

        App-only tokens carry no refresh token by design, so renewal means signing a fresh
        assertion. That costs one local RSA signature, which is why there is no attempt to
        persist tokens between sessions - it would trade a millisecond of CPU for a bearer
        token sitting on disk.

    .PARAMETER Force
        Requests a new token even if the cached one is still valid

    .PARAMETER AsPlainText
        Returns the raw token string instead of the summary object. Intended for handing a
        bearer token to another tool; be aware it will appear in a transcript.

    .PARAMETER Connection
        Connection to use instead of the module's active one. Connect-EntraEnvironment
        passes this to validate a credential before storing it.

    .OUTPUTS
        EntraAccessToken, or System.String with -AsPlainText

    .EXAMPLE
        PS> Get-EntraAccessToken

        DESCRIPTION: Returns the cached token's metadata, renewing it if it is close to expiry
        OUTPUT: An object with ExpiresOn, ExpiresInMinutes and the granted roles
        USE CASE: Checking what the app is actually authorised to do before a seed run

    .EXAMPLE
        PS> $bearer = Get-EntraAccessToken -AsPlainText

        DESCRIPTION: Retrieves the raw token
        OUTPUT: eyJ0eXAiOiJKV1QiLCJub25jZSI6...
        USE CASE: Calling Graph from curl, or from a tool that takes a bearer token directly

    .NOTES
        Author: Jeffrey Stuhr
        Blog: https://www.techbyjeff.net
        LinkedIn: https://www.linkedin.com/in/jeffrey-stuhr-034214aa/
    #>


    [CmdletBinding()]
    [OutputType('EntraAccessToken')]
    param(
        [Parameter()]
        [switch]$Force,

        [Parameter()]
        [switch]$AsPlainText,

        [Parameter()]
        [hashtable]$Connection
    )

    if (-not $Connection) { $Connection = Get-EntraConnection }

    $needsToken = $Force -or
        [string]::IsNullOrWhiteSpace($Connection.AccessToken) -or
        ($null -eq $Connection.TokenExpiresOn) -or
        ([DateTimeOffset]::UtcNow.AddMinutes(2) -ge $Connection.TokenExpiresOn)

    if ($needsToken) {
        $tokenUri = "https://login.microsoftonline.com/$($Connection.TenantId)/oauth2/v2.0/token"

        # Form encoding, not JSON. The token endpoint is OAuth 2.0 rather than Graph, and it
        # answers a JSON body with an unhelpful 400 rather than an error naming the format.
        $form = if ($Connection.AuthMode -eq 'DeviceCode') {
            # A delegated session, renewed with the refresh token the device code flow
            # returned. There is no certificate to sign with here, and no way to obtain a new
            # token without the human, so an expired refresh token has to fail loudly rather
            # than silently degrade.
            if (-not $Connection.RefreshToken) {
                Write-Error ("The interactive session has expired and there is no refresh token to renew it. " +
                    "Run Connect-TestEnvironment -Provider Entra -Interactive again.") -ErrorAction Stop
                return
            }
            @{
                client_id     = $Connection.ClientId
                scope         = "$($Connection.GraphBaseUri)/.default offline_access"
                grant_type    = 'refresh_token'
                refresh_token = $Connection.RefreshToken
            }
        }
        else {
            $assertion = New-EntraClientAssertion -Certificate $Connection.Certificate `
                -ClientId $Connection.ClientId -TenantId $Connection.TenantId
            @{
                client_id             = $Connection.ClientId
                scope                 = "$($Connection.GraphBaseUri)/.default"
                grant_type            = 'client_credentials'
                client_assertion_type = 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer'
                client_assertion      = $assertion
            }
        }
        $body = ($form.GetEnumerator() | ForEach-Object {
            '{0}={1}' -f [uri]::EscapeDataString($_.Key), [uri]::EscapeDataString([string]$_.Value)
        }) -join '&'

        $previousProgress = $ProgressPreference
        $ProgressPreference = 'SilentlyContinue'
        try {
            Write-Verbose "Requesting an access token from $tokenUri"
            $response = Invoke-WebRequest -Uri $tokenUri -Method POST `
                -Body ([System.Text.Encoding]::UTF8.GetBytes($body)) `
                -ContentType 'application/x-www-form-urlencoded' `
                -UseBasicParsing -ErrorAction Stop

            $payload = ([System.Text.Encoding]::UTF8.GetString($response.RawContentStream.ToArray())) | ConvertFrom-Json
        }
        catch {
            # Entra puts the actionable part in error_description, including the AADSTS code,
            # and PowerShell discards the response body by default on both editions.
            $detail = Get-EntraErrorDetail -ErrorRecord $_
            throw (New-Object System.Exception("Token request failed for client $($Connection.ClientId): $detail", $_.Exception))
        }
        finally {
            $ProgressPreference = $previousProgress
        }

        $Connection.AccessToken = $payload.access_token
        $Connection.TokenExpiresOn = [DateTimeOffset]::UtcNow.AddSeconds([int]$payload.expires_in)
        $Connection.TokenRoles = @(Get-EntraTokenRole -AccessToken $payload.access_token)

        # Entra rotates the refresh token on every use, so the old one stops working the
        # moment this succeeds. Keeping the previous value would make the session survive
        # exactly one renewal.
        if ($payload.PSObject.Properties['refresh_token'] -and $payload.refresh_token) {
            $Connection.RefreshToken = $payload.refresh_token
        }
        Write-Verbose "Token acquired, valid until $($Connection.TokenExpiresOn.ToLocalTime())"
    }

    if ($AsPlainText) { return $Connection.AccessToken }

    return [PSCustomObject]@{
        PSTypeName       = 'EntraAccessToken'
        TenantId         = $Connection.TenantId
        ClientId         = $Connection.ClientId
        ExpiresOn        = $Connection.TokenExpiresOn.ToLocalTime()
        ExpiresInMinutes = [Math]::Round(($Connection.TokenExpiresOn - [DateTimeOffset]::UtcNow).TotalMinutes, 1)
        Roles            = $Connection.TokenRoles
    }
}