Providers/Entra/Private/New-EntraClientAssertion.ps1

function New-EntraClientAssertion {
    <#
    .SYNOPSIS
        Builds and signs the RS256 JWT Entra accepts in place of a client secret

    .DESCRIPTION
        Certificate credentials on a confidential client work by private_key_jwt: the caller
        signs a short-lived assertion with the certificate's private key, and Entra verifies
        it against the public key already registered on the application. Nothing secret ever
        crosses the wire, which is the whole reason to prefer this over a client secret.

        Two details are worth knowing because both fail obscurely.

        The 'x5t' header is how Entra decides which registered key to verify against, and it
        is the base64url encoding of the certificate's SHA-1 hash BYTES. The thumbprint that
        the certificate store shows is that same hash rendered as hex, so it has to be decoded
        back to bytes first. Base64url-encoding the hex string instead produces a well-formed
        assertion that Entra rejects with AADSTS700027 - it cannot find a key matching the
        thumbprint you did not send.

        The 'aud' claim must be the v2.0 token endpoint for the specific tenant. A v1.0
        audience, or the common endpoint, is rejected with AADSTS50027 for a token that is
        otherwise entirely valid.

        Lifetime is deliberately short. The assertion is single use in practice and Entra
        allows up to ten minutes; five is enough to absorb ordinary clock skew without
        leaving a usable credential lying around in a transcript. 'nbf' is backdated by
        thirty seconds for the same reason - a workstation clock a few seconds ahead of
        Microsoft's is common, and an assertion not yet valid is refused outright.

    .PARAMETER Certificate
        The signing certificate, which must hold an RSA private key

    .PARAMETER ClientId
        Application (client) ID, used as both issuer and subject

    .PARAMETER TenantId
        Directory (tenant) ID, which selects the token endpoint named in the audience

    .PARAMETER LifetimeMinutes
        How long the assertion stays valid

    .OUTPUTS
        System.String. The signed JWT, as three base64url segments separated by dots.

    .EXAMPLE
        PS> New-EntraClientAssertion -Certificate $cert -ClientId $appId -TenantId $tenant

        DESCRIPTION: Signs an assertion for the client credentials grant
        OUTPUT: eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsIng1dCI6Ii4uLiJ9.eyJhdWQiOiJodHRwczov...
        USE CASE: Called by Get-EntraAccessToken on every token request

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


    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '',
        Justification = 'Changes no state. It computes a value in memory and returns it; the New verb describes constructing an object, not modifying anything.')]
    [CmdletBinding()]
    [OutputType([string])]
    param(
        [Parameter(Mandatory = $true)]
        [System.Security.Cryptography.X509Certificates.X509Certificate2]$Certificate,

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

        [Parameter(Mandatory = $true)]
        [string]$TenantId,

        [Parameter()]
        [ValidateRange(1, 10)]
        [int]$LifetimeMinutes = 5
    )

    $rsa = [System.Security.Cryptography.X509Certificates.RSACertificateExtensions]::GetRSAPrivateKey($Certificate)
    if (-not $rsa) {
        Write-Error "Certificate $($Certificate.Thumbprint) exposes no RSA private key to sign with." -ErrorAction Stop
        return
    }

    # GetCertHash() returns the SHA-1 hash as bytes. That is the same value the store renders
    # as the hex thumbprint, and it is the bytes Entra wants base64url-encoded here.
    $x5t = ConvertTo-TestBase64Url -Bytes $Certificate.GetCertHash()

    $header = [ordered]@{
        alg = 'RS256'
        typ = 'JWT'
        x5t = $x5t
    }

    $now = [DateTimeOffset]::UtcNow
    $claims = [ordered]@{
        aud = "https://login.microsoftonline.com/$TenantId/oauth2/v2.0/token"
        iss = $ClientId
        sub = $ClientId
        jti = [guid]::NewGuid().ToString()
        nbf = $now.AddSeconds(-30).ToUnixTimeSeconds()
        exp = $now.AddMinutes($LifetimeMinutes).ToUnixTimeSeconds()
        iat = $now.ToUnixTimeSeconds()
    }

    # -Compress matters beyond neatness: the signature covers these exact bytes, so any
    # whitespace ConvertTo-Json would otherwise insert becomes part of what was signed.
    $headerSegment = ConvertTo-TestBase64Url -Bytes ([System.Text.Encoding]::UTF8.GetBytes(($header | ConvertTo-Json -Compress)))
    $claimsSegment = ConvertTo-TestBase64Url -Bytes ([System.Text.Encoding]::UTF8.GetBytes(($claims | ConvertTo-Json -Compress)))

    $signingInput = "$headerSegment.$claimsSegment"
    $signature = $rsa.SignData(
        [System.Text.Encoding]::ASCII.GetBytes($signingInput),
        [System.Security.Cryptography.HashAlgorithmName]::SHA256,
        [System.Security.Cryptography.RSASignaturePadding]::Pkcs1)

    return "$signingInput.$(ConvertTo-TestBase64Url -Bytes $signature)"
}