Private/Common/New-MgcClientAssertion.ps1
|
function New-MgcClientAssertion { <# .SYNOPSIS Builds a signed JWT client assertion for certificate-based client credentials. .DESCRIPTION Creates an RS256-signed JWT with: - Header: alg=RS256, typ=JWT, x5t = base64url(SHA-1 of cert raw bytes) - Body: aud (token endpoint), iss/sub (ClientId), jti (random), nbf, exp Uses the certificate's private key to sign. The cert MUST have a usable private key. .PARAMETER ClientId App registration Client ID. .PARAMETER TokenEndpoint Full token endpoint URL (audience of the assertion). .PARAMETER Certificate X509Certificate2 with private key. .PARAMETER LifetimeSeconds Assertion lifetime. Defaults to 600 (10 min) - AAD allows up to ~24h. #> [CmdletBinding()] param( [Parameter(Mandatory)][string]$ClientId, [Parameter(Mandatory)][string]$TokenEndpoint, [Parameter(Mandatory)][System.Security.Cryptography.X509Certificates.X509Certificate2]$Certificate, [int]$LifetimeSeconds = 600 ) if (-not $Certificate.HasPrivateKey) { throw "Certificate has no usable private key." } $rsa = [System.Security.Cryptography.X509Certificates.RSACertificateExtensions]::GetRSAPrivateKey($Certificate) if (-not $rsa) { throw "Certificate private key is not RSA. Only RSA-signed assertions are supported." } # x5t = base64url(SHA-1(cert raw bytes)) per RFC 7515 # Cross-version safe: use Create()+ComputeHash instead of PS 7.1+ [SHA1]::HashData. $sha1 = [System.Security.Cryptography.SHA1]::Create() try { $thumbHash = $sha1.ComputeHash($Certificate.RawData) } finally { $sha1.Dispose() } $x5t = [Convert]::ToBase64String($thumbHash).TrimEnd('=').Replace('+','-').Replace('/','_') $header = [ordered]@{ alg = 'RS256' typ = 'JWT' x5t = $x5t } # Culture- and timezone-safe Unix timestamp. Piping '1970-01-01T00:00:00Z' # through Get-Date converts the epoch to LOCAL time, which skews nbf/exp by # the machine's UTC offset and makes Entra ID reject the assertion outside # near-UTC timezones. Subtract a true UTC epoch instead. $epoch = New-Object DateTime 1970, 1, 1, 0, 0, 0, ([DateTimeKind]::Utc) $now = [int64]([DateTime]::UtcNow - $epoch).TotalSeconds $body = [ordered]@{ aud = $TokenEndpoint iss = $ClientId sub = $ClientId jti = [Guid]::NewGuid().ToString('N') nbf = $now exp = $now + $LifetimeSeconds } $encode = { param($obj) $json = $obj | ConvertTo-Json -Compress -Depth 10 $bytes = [System.Text.Encoding]::UTF8.GetBytes($json) return [Convert]::ToBase64String($bytes).TrimEnd('=').Replace('+','-').Replace('/','_') } $segHeader = & $encode $header $segBody = & $encode $body $unsigned = "$segHeader.$segBody" $sig = $rsa.SignData( [System.Text.Encoding]::ASCII.GetBytes($unsigned), [System.Security.Cryptography.HashAlgorithmName]::SHA256, [System.Security.Cryptography.RSASignaturePadding]::Pkcs1 ) $segSig = [Convert]::ToBase64String($sig).TrimEnd('=').Replace('+','-').Replace('/','_') return "$unsigned.$segSig" } |