Modules/AzureDevOpsDsc.Common/Api/Functions/Private/Authentication/Certificate/Build-JWTAssertion.ps1

<#
.SYNOPSIS
    Builds a signed JWT client assertion for OAuth 2.0 certificate-based authentication.
 
.DESCRIPTION
    Constructs a JWT with RS256 signing using the provided X.509 certificate. The resulting
    JWT is suitable for use as the client_assertion in an OAuth 2.0 token request.
 
.PARAMETER Certificate
    An X509Certificate2 object with an accessible private key (RSA).
 
.PARAMETER TenantId
    Azure AD tenant ID, used to construct the audience claim.
 
.PARAMETER ClientId
    Application (client) ID, used as the issuer and subject claims.
#>

Function Build-JWTAssertion
{
    [CmdletBinding()]
    param (
        [Parameter(Mandatory = $true)]
        [System.Security.Cryptography.X509Certificates.X509Certificate2]$Certificate,

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

        [Parameter(Mandatory = $true)]
        [String]$ClientId
    )

    # Helper: Base64Url encode without padding
    function ConvertTo-Base64UrlEncoding ([byte[]]$bytes)
    {
        return [Convert]::ToBase64String($bytes).TrimEnd('=').Replace('+', '-').Replace('/', '_')
    }

    # Build x5t (SHA-1 thumbprint as base64url)
    $thumbprintBytes = [byte[]] -split ($Certificate.Thumbprint -replace '..', '0x$& ')
    $x5t = ConvertTo-Base64UrlEncoding $thumbprintBytes

    # JWT header
    $headerObj = [ordered]@{
        alg = 'RS256'
        typ = 'JWT'
        x5t = $x5t
    }
    $headerJson  = $headerObj | ConvertTo-Json -Compress
    $headerB64   = ConvertTo-Base64UrlEncoding ([System.Text.Encoding]::UTF8.GetBytes($headerJson))

    # JWT payload
    $now        = [DateTimeOffset]::UtcNow
    $expSeconds = $now.AddMinutes(10).ToUnixTimeSeconds()
    $nbfSeconds = $now.ToUnixTimeSeconds()
    $audience   = "https://login.microsoftonline.com/$TenantId/oauth2/token"

    $payloadObj = [ordered]@{
        iss = $ClientId
        sub = $ClientId
        aud = $audience
        exp = $expSeconds
        nbf = $nbfSeconds
        jti = [Guid]::NewGuid().ToString()
    }
    $payloadJson = $payloadObj | ConvertTo-Json -Compress
    $payloadB64  = ConvertTo-Base64UrlEncoding ([System.Text.Encoding]::UTF8.GetBytes($payloadJson))

    # Data to sign
    $signingInput = "$headerB64.$payloadB64"
    $signingBytes = [System.Text.Encoding]::UTF8.GetBytes($signingInput)

    # Sign with RSA SHA-256 using the certificate private key.
    # GetRSAPrivateKey() is an extension method (RSACertificateExtensions) - PowerShell's member-
    # access syntax can fail to resolve it depending on assembly load order in the session, so call
    # it via its fully-qualified static form instead of $Certificate.GetRSAPrivateKey().
    $rsa = [System.Security.Cryptography.X509Certificates.RSACertificateExtensions]::GetRSAPrivateKey($Certificate)
    if ($null -eq $rsa)
    {
        throw "[Build-JWTAssertion] Certificate does not have an accessible RSA private key."
    }

    $signatureBytes = $rsa.SignData($signingBytes, [System.Security.Cryptography.HashAlgorithmName]::SHA256, [System.Security.Cryptography.RSASignaturePadding]::Pkcs1)
    $signatureB64   = ConvertTo-Base64UrlEncoding $signatureBytes

    return "$signingInput.$signatureB64"
}