Modules/businessdev.ALbuild.OnPrem/Private/New-BcKeyVaultClientAssertion.ps1

function New-BcKeyVaultClientAssertion {
    <#
    .SYNOPSIS
        Builds a signed JWT client_assertion for an Azure AD app registration, signing it with a
        certificate held in Azure Key Vault.
 
    .DESCRIPTION
        Produces the assertion used by the OAuth2 'client_credentials' flow with a
        client_assertion_type of 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer'.
 
        The certificate's private key never leaves Key Vault: a service principal authenticates to
        the vault, the public certificate metadata (x5t thumbprint + key id) is read, the JWT
        header/payload are assembled on the agent, and the RS256 signature is produced by the Key
        Vault key 'sign' operation. The vault SP therefore needs 'certificates/get' and 'keys/sign'.
 
    .PARAMETER TenantId
        Azure AD tenant id of the app registration. The token endpoint for this tenant is the
        assertion audience.
 
    .PARAMETER ClientId
        Application (client) id of the app registration (used as the assertion issuer and subject).
 
    .PARAMETER KeyVaultUrl
        Key Vault base URL, e.g. https://my-vault.vault.azure.net.
 
    .PARAMETER CertificateName
        Certificate name in the vault.
 
    .PARAMETER KeyVaultTenantId
        Azure AD tenant of the service principal used to access the vault.
 
    .PARAMETER KeyVaultClientId
        Application (client) id of the service principal used to access the vault.
 
    .PARAMETER KeyVaultClientSecret
        Client secret of the service principal used to access the vault.
 
    .PARAMETER ApiVersion
        Key Vault REST api-version. Default '7.4'.
 
    .OUTPUTS
        System.String. The compact-serialised, signed JWT assertion.
    #>

    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '',
        Justification = 'Builds and returns a signed JWT; it does not change persistent system state.')]
    [CmdletBinding()]
    [OutputType([string])]
    param(
        [Parameter(Mandatory)] [string] $TenantId,
        [Parameter(Mandatory)] [string] $ClientId,
        [Parameter(Mandatory)] [string] $KeyVaultUrl,
        [Parameter(Mandatory)] [string] $CertificateName,
        [Parameter(Mandatory)] [string] $KeyVaultTenantId,
        [Parameter(Mandatory)] [string] $KeyVaultClientId,
        [Parameter(Mandatory)] [string] $KeyVaultClientSecret,
        [string] $ApiVersion = '7.4'
    )

    # Base64Url-encode raw bytes (RFC 7515: '+'->'-', '/'->'_', strip '=' padding).
    function ConvertTo-Base64Url([byte[]] $Bytes) {
        [Convert]::ToBase64String($Bytes).TrimEnd('=').Replace('+', '-').Replace('/', '_')
    }

    # Read a field defensively so a missing one yields a clear message under Set-StrictMode.
    function Get-Field([object] $Object, [string] $Name) {
        $prop = $Object.PSObject.Properties[$Name]
        if ($prop) { $prop.Value } else { $null }
    }

    # 1. Get a Key Vault access token for the vault data-plane.
    $vaultTokenBody = @{
        client_id     = $KeyVaultClientId
        client_secret = $KeyVaultClientSecret
        scope         = 'https://vault.azure.net/.default'
        grant_type    = 'client_credentials'
    }
    $vaultTokenResponse = Invoke-RestMethod -Uri "https://login.microsoftonline.com/$KeyVaultTenantId/oauth2/v2.0/token" `
        -Method Post -Body $vaultTokenBody -ContentType 'application/x-www-form-urlencoded' -ErrorAction Stop
    $vaultHeaders = @{ Authorization = "Bearer $(Get-Field $vaultTokenResponse 'access_token')" }

    # 2. Read the certificate to obtain its x5t (SHA-1 thumbprint) and the backing key id (kid).
    $base = $KeyVaultUrl.TrimEnd('/')
    $cert = Invoke-RestMethod -Uri "$base/certificates/$CertificateName/?api-version=$ApiVersion" -Headers $vaultHeaders -Method Get -ErrorAction Stop
    $kid = Get-Field $cert 'kid'
    if (-not $kid) { throw "Key Vault certificate '$CertificateName' did not return a key id (kid)." }
    $x5t = Get-Field $cert 'x5t'  # already base64url-encoded by Key Vault

    # 3. Assemble the JWT header and payload.
    $now = [DateTimeOffset]::UtcNow
    $header = @{ alg = 'RS256'; typ = 'JWT'; x5t = $x5t } | ConvertTo-Json -Compress
    $payload = @{
        aud = "https://login.microsoftonline.com/$TenantId/oauth2/v2.0/token"
        iss = $ClientId
        sub = $ClientId
        jti = [guid]::NewGuid().ToString()
        nbf = $now.ToUnixTimeSeconds()
        exp = $now.AddMinutes(10).ToUnixTimeSeconds()
    } | ConvertTo-Json -Compress

    $enc = [System.Text.Encoding]::UTF8
    $signingInput = (ConvertTo-Base64Url $enc.GetBytes($header)) + '.' + (ConvertTo-Base64Url $enc.GetBytes($payload))

    # 4. Hash the signing input and have Key Vault produce the RS256 signature (private key stays in KV).
    $digest = [System.Security.Cryptography.SHA256]::Create().ComputeHash($enc.GetBytes($signingInput))
    $signBody = @{ alg = 'RS256'; value = (ConvertTo-Base64Url $digest) } | ConvertTo-Json -Compress
    $signResult = Invoke-RestMethod -Uri "$kid/sign?api-version=$ApiVersion" -Headers ($vaultHeaders + @{ 'Content-Type' = 'application/json' }) `
        -Method Post -Body $signBody -ErrorAction Stop
    $signature = Get-Field $signResult 'value'
    if (-not $signature) { throw "Key Vault did not return a signature for certificate '$CertificateName'." }

    # 5. Compact-serialise the signed JWT.
    return "$signingInput.$signature"
}