Private/New-MsecClientAssertion.ps1

function New-MsecClientAssertion {
    <#
    .SYNOPSIS
        Builds a signed JWT client assertion (RFC 7523) where the signature is produced by
        Azure Key Vault - the private key never leaves the vault.

    .DESCRIPTION
        Assembles the JWT header + payload locally, SHA-256 hashes the signing input, and
        calls Invoke-AzKeyVaultKeyOperation -Operation Sign -Algorithm RS256 on the key
        associated with the msec certificate. The returned signature bytes are base64url-
        encoded and appended to form the assertion:

            <header>.<payload>.<signature-from-kv>

        Permissions: the caller needs 'Key Vault Crypto User' on the key. The signing
        operation does not return the private key material.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][string] $TenantId,
        [Parameter(Mandatory)][string] $ClientId,
        [Parameter(Mandatory)][string] $VaultName,
        [Parameter(Mandatory)][string] $KeyName,

        # SHA-1 thumbprint of the public cert, as raw bytes. Goes into the JWT x5t header.
        [Parameter(Mandatory)]
        [byte[]] $ThumbprintBytes,

        # AAD login authority for the target cloud (no trailing slash), e.g.
        # 'https://login.microsoftonline.com' (commercial) or
        # 'https://login.chinacloudapi.cn' (Azure China). The aud claim MUST match the
        # token endpoint Get-MsecAccessToken POSTs to, or Entra rejects the assertion.
        [Parameter()]
        [string] $Authority = 'https://login.microsoftonline.com',

        # Assertion lifetime in seconds; Entra accepts up to ~600.
        [Parameter()]
        [int] $LifetimeSeconds = 300
    )

    $now = [DateTimeOffset]::UtcNow.ToUnixTimeSeconds()
    $header = [ordered]@{
        alg = 'RS256'
        typ = 'JWT'
        x5t = ConvertTo-MsecBase64Url -InputObject $ThumbprintBytes
    }
    $payload = [ordered]@{
        aud = "$($Authority.TrimEnd('/'))/$TenantId/oauth2/v2.0/token"
        iss = $ClientId
        sub = $ClientId
        jti = [guid]::NewGuid().Guid
        nbf = $now
        exp = $now + $LifetimeSeconds
    }

    $headerJson   = ($header  | ConvertTo-Json -Compress)
    $payloadJson  = ($payload | ConvertTo-Json -Compress)
    $signingInput = (ConvertTo-MsecBase64Url $headerJson) + '.' + (ConvertTo-MsecBase64Url $payloadJson)

    # SHA-256 the signing input locally; KV signs the pre-computed digest for RS256.
    $sha = [System.Security.Cryptography.SHA256]::Create()
    try {
        $digest = $sha.ComputeHash([System.Text.Encoding]::UTF8.GetBytes($signingInput))
    }
    finally {
        $sha.Dispose()
    }

    [byte[]] $sigBytes = Invoke-MsecKeyVaultSign `
        -VaultName $VaultName -KeyName $KeyName -Digest $digest -Algorithm 'RS256'

    $signingInput + '.' + (ConvertTo-MsecBase64Url $sigBytes)
}