Private/New-ClientAssertion.ps1
|
function New-ClientAssertion { <# .SYNOPSIS Builds a signed JWT client assertion from an X509Certificate2 for certificate-based client credentials authentication. .DESCRIPTION Constructs a JWT with the required header (alg, typ, x5t) and payload (aud, iss, sub, jti, nbf, exp), then signs it with the certificate's RSA private key using RS256. The resulting JWT is used as the client_assertion parameter in the token request. The certificate's private key never leaves the process — only the signed assertion string is returned. .PARAMETER ClientId The application (client) ID of the app registration. .PARAMETER TenantId The Azure AD / Entra ID tenant ID (GUID or domain). .PARAMETER ClientCertificate The X509Certificate2 containing the private key used to sign the assertion. .PARAMETER LifetimeMinutes The lifetime of the assertion in minutes. Defaults to 5 to minimize replay risk. .NOTES Author: Nickolaj Andersen & Jan Ketil Skanke Contact: @NickolajA @JankeSkanke Created: 2026-02-19 Version history: 1.0.0 - (2026-02-19) Script created #> [CmdletBinding()] param( [Parameter(Mandatory = $true)] [ValidateNotNullOrEmpty()] [string]$ClientId, [Parameter(Mandatory = $true)] [ValidateNotNullOrEmpty()] [string]$TenantId, [Parameter(Mandatory = $true)] [ValidateNotNullOrEmpty()] [System.Security.Cryptography.X509Certificates.X509Certificate2]$ClientCertificate, [Parameter(Mandatory = $false)] [int]$LifetimeMinutes = 5 ) Process { # Validate the certificate has a private key if (-not $ClientCertificate.HasPrivateKey) { throw "The provided certificate does not contain a private key. A private key is required to sign the client assertion." } $tokenEndpoint = "https://login.microsoftonline.com/$TenantId/oauth2/v2.0/token" # Helper: Convert bytes to base64url string function ConvertTo-Base64Url { param([byte[]]$Bytes) [Convert]::ToBase64String($Bytes) -replace '\+', '-' -replace '/', '_' -replace '=' } # Build x5t (base64url-encoded SHA-1 thumbprint of the certificate) $thumbprintBytes = $ClientCertificate.GetCertHash() # SHA-1 by default $x5t = ConvertTo-Base64Url -Bytes $thumbprintBytes # Build JWT header $header = @{ alg = "RS256" typ = "JWT" x5t = $x5t } | ConvertTo-Json -Compress # Build JWT payload $now = [DateTimeOffset]::UtcNow $payload = @{ aud = $tokenEndpoint iss = $ClientId sub = $ClientId jti = [guid]::NewGuid().ToString() nbf = $now.ToUnixTimeSeconds() exp = $now.AddMinutes($LifetimeMinutes).ToUnixTimeSeconds() } | ConvertTo-Json -Compress # Encode header and payload as base64url $headerBase64 = ConvertTo-Base64Url -Bytes ([System.Text.Encoding]::UTF8.GetBytes($header)) $payloadBase64 = ConvertTo-Base64Url -Bytes ([System.Text.Encoding]::UTF8.GetBytes($payload)) # Construct the signing input $signingInput = "$headerBase64.$payloadBase64" # Sign with RSA-SHA256 using the certificate's private key $rsa = if ($ClientCertificate | Get-Member -Name 'GetRSAPrivateKey' -MemberType Method -ErrorAction SilentlyContinue) { $ClientCertificate.GetRSAPrivateKey() } else { $ClientCertificate.PrivateKey } if (-not $rsa) { throw "Could not extract RSA private key from the certificate." } $signatureBytes = $rsa.SignData( [System.Text.Encoding]::UTF8.GetBytes($signingInput), [System.Security.Cryptography.HashAlgorithmName]::SHA256, [System.Security.Cryptography.RSASignaturePadding]::Pkcs1 ) $signatureBase64 = ConvertTo-Base64Url -Bytes $signatureBytes # Return the complete JWT return "$signingInput.$signatureBase64" } } |