Working solution using Microsoft.Graph.Authentication Module only.
Tested under powershell 7.4.1 and Windows PowerShell 5.1.22621.2506
For me the error was that I did not remove the padding in both jwt headers, I also don't see anything inherently wrong with your version, but it might be that your datetimes are not in UTC. not that you are likely to read this two and a half years later.
# Replace for your Environment
Connect-MgGraph -TenantId $graphConfig.tenantID -ClientId $graphConfig.unprivClientID -CertificateThumbprint $graphConfig.thumb
# Application / Client ID of the App you are rotating the Certificate on
$clientID = $graphConfig.unprivClientID
# Load Certificate from Windows Store
$cert = Get-Item "Cert:\CurrentUser\My\$($graphConfig.thumb)" -ErrorAction Stop
# Alternative from a Certificate file, drop in Replacement
# $cert = Get-PfxCertificate "[pathToCer]"
# Get base64 hash of certificate in Web Encoding
$CertificateBase64Hash = [System.Convert]::ToBase64String($cert.GetCertHash()) -replace '\+', '-' -replace '/', '_' -replace '='
# Very far removed, but still based on https://adamtheautomator.com/powershell-graph-api/
$StartDate = (Get-Date "1970-01-01T00:00:00Z").ToUniversalTime()
$now = (Get-Date).ToUniversalTime()
# Create JWT timestamp for expiration
$JWTExpirationTimeSpan = ( New-TimeSpan -Start $StartDate -End $now.AddMinutes(2) ).TotalSeconds
$JWTExpiration = [math]::Round($JWTExpirationTimeSpan, 0)
# Create JWT validity start timestamp
$NotBeforeExpirationTimeSpan = ( New-TimeSpan -Start $StartDate -End $now ).TotalSeconds
$NotBefore = [math]::Round($NotBeforeExpirationTimeSpan, 0)
# Create JWT header
$JWTHeader = @{
alg = "RS256"
typ = "JWT"
x5t = $CertificateBase64Hash
}
# Create JWT payload
$JWTPayLoad = @{
aud = "00000002-0000-0000-c000-000000000000"
exp = $JWTExpiration
iss = $clientID
jti = [guid]::NewGuid()
nbf = $NotBefore
sub = $clientID
}
# Check if there are Equals in the JWT to replace, since that is referenced in one of the articles (add)
# Convert header and payload to base64
$JWTHeaderToByte = [System.Text.Encoding]::UTF8.GetBytes(($JWTHeader | ConvertTo-Json))
$EncodedHeader = [System.Convert]::ToBase64String($JWTHeaderToByte) -replace '='
$JWTPayLoadToByte = [System.Text.Encoding]::UTF8.GetBytes(($JWTPayload | ConvertTo-Json))
$EncodedPayload = [System.Convert]::ToBase64String($JWTPayLoadToByte) -replace '='
$JWT = $EncodedHeader + "." + $EncodedPayload
# Define RSA signature and hashing algorithm
$RSAPadding = [Security.Cryptography.RSASignaturePadding]::Pkcs1
$HashAlgorithm = [Security.Cryptography.HashAlgorithmName]::SHA256
# Sign the JWT
$rsaCert = [System.Security.Cryptography.X509Certificates.RSACertificateExtensions]::GetRSAPrivateKey($cert)
$Signature = [Convert]::ToBase64String(
$rsaCert.SignData([System.Text.Encoding]::UTF8.GetBytes($JWT), $HashAlgorithm, $RSAPadding)
) -replace '\+', '-' -replace '/', '_' -replace '='
# Add Signature to JWT
$JWT = $JWT + "." + $Signature
# Load the new Certificate, again use Get-PfxCertificate as a drop in Replacement
$newCert = Get-Item "Cert:\CurrentUser\My\$($graphConfig.newThumb)" -ErrorAction Stop
$params = @{
keyCredential = @{
type = "AsymmetricX509Cert"
usage = "Verify"
key = [convert]::ToBase64String($newCert.GetRawCertData())
}
passwordCredential = $null
proof = $JWT
}
Invoke-MgGraphRequest POST "/v1.0/applications(appId='$clientID')/addKey" -Body $params