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" } |