Private/Common/New-MgcClientAssertion.ps1

function New-MgcClientAssertion {
    <#
    .SYNOPSIS
        Builds a signed JWT client assertion for certificate-based client credentials.

    .DESCRIPTION
        Creates an RS256-signed JWT with:
          - Header: alg=RS256, typ=JWT, x5t = base64url(SHA-1 of cert raw bytes)
          - Body: aud (token endpoint), iss/sub (ClientId), jti (random), nbf, exp
        Uses the certificate's private key to sign. The cert MUST have a usable
        private key.

    .PARAMETER ClientId
        App registration Client ID.

    .PARAMETER TokenEndpoint
        Full token endpoint URL (audience of the assertion).

    .PARAMETER Certificate
        X509Certificate2 with private key.

    .PARAMETER LifetimeSeconds
        Assertion lifetime. Defaults to 600 (10 min) - AAD allows up to ~24h.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][string]$ClientId,
        [Parameter(Mandatory)][string]$TokenEndpoint,
        [Parameter(Mandatory)][System.Security.Cryptography.X509Certificates.X509Certificate2]$Certificate,
        [int]$LifetimeSeconds = 600
    )

    if (-not $Certificate.HasPrivateKey) {
        throw "Certificate has no usable private key."
    }

    $rsa = [System.Security.Cryptography.X509Certificates.RSACertificateExtensions]::GetRSAPrivateKey($Certificate)
    if (-not $rsa) {
        throw "Certificate private key is not RSA. Only RSA-signed assertions are supported."
    }

    # x5t = base64url(SHA-1(cert raw bytes)) per RFC 7515
    # Cross-version safe: use Create()+ComputeHash instead of PS 7.1+ [SHA1]::HashData.
    $sha1 = [System.Security.Cryptography.SHA1]::Create()
    try {
        $thumbHash = $sha1.ComputeHash($Certificate.RawData)
    } finally {
        $sha1.Dispose()
    }
    $x5t = [Convert]::ToBase64String($thumbHash).TrimEnd('=').Replace('+','-').Replace('/','_')

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

    # Culture- and timezone-safe Unix timestamp. Piping '1970-01-01T00:00:00Z'
    # through Get-Date converts the epoch to LOCAL time, which skews nbf/exp by
    # the machine's UTC offset and makes Entra ID reject the assertion outside
    # near-UTC timezones. Subtract a true UTC epoch instead.
    $epoch = New-Object DateTime 1970, 1, 1, 0, 0, 0, ([DateTimeKind]::Utc)
    $now   = [int64]([DateTime]::UtcNow - $epoch).TotalSeconds
    $body = [ordered]@{
        aud = $TokenEndpoint
        iss = $ClientId
        sub = $ClientId
        jti = [Guid]::NewGuid().ToString('N')
        nbf = $now
        exp = $now + $LifetimeSeconds
    }

    $encode = {
        param($obj)
        $json = $obj | ConvertTo-Json -Compress -Depth 10
        $bytes = [System.Text.Encoding]::UTF8.GetBytes($json)
        return [Convert]::ToBase64String($bytes).TrimEnd('=').Replace('+','-').Replace('/','_')
    }

    $segHeader = & $encode $header
    $segBody   = & $encode $body
    $unsigned  = "$segHeader.$segBody"

    $sig = $rsa.SignData(
        [System.Text.Encoding]::ASCII.GetBytes($unsigned),
        [System.Security.Cryptography.HashAlgorithmName]::SHA256,
        [System.Security.Cryptography.RSASignaturePadding]::Pkcs1
    )
    $segSig = [Convert]::ToBase64String($sig).TrimEnd('=').Replace('+','-').Replace('/','_')

    return "$unsigned.$segSig"
}