I’ve been struggling with the error “Exception calling “SignData” with “3” argument(s): “Invalid algorithm specified” when authenticating to Azure AD with a client certificate from PowerShell. After some digging, I was able to solve this issue for the scenario in which I was using this.
So, my intention was to perform calls to the Microsoft Graph from PowerShell without additional modules, using an access token requested through certificate authentication.
Doing plenty of prerequisite activities to support data migrations, like what I described in How to initialize private teamchannel SPO sites, I wanted to use certificate authentication. That way I could perform certain bulk-activities unattended.
Disclaimer: self-signed certificates are not trusted by default. For production environments, purchase a commercial certificate instead.
Getting the access token
function New-AccessTokenCertificate {
[CmdletBinding()]
[OutputType('PSCustomObject')]
param(
[Parameter(Mandatory = $true)]
[string]$clientId,
[Parameter(Mandatory = $true)]
[object]$certificateLocation,
[Parameter(Mandatory = $true)]
[string]$tenantId,
[Parameter(Mandatory = $true)]
[secureString]$thumbPrint,
[Parameter(Mandatory = $false)]
[string]$scope = 'https://graph.microsoft.com/.default')
try {
#Convert thumbprint secure string to plain text
[string]$thumbPrint = [System.Runtime.InteropServices.Marshal]::PtrToStringAuto([System.Runtime.InteropServices.Marshal]::SecureStringToBSTR($thumbPrint))
#Get the certificate from the Certificate store
$certificate = Get-ChildItem $certificateLocation | Where-Object { $_.thumbprint -eq $thumbPrint }
#Create base64 hash of certificate
$certificateBase64Hash = [System.Convert]::ToBase64String($certificate.GetCertHash())
#Create JWT timestamp for expiration
$StartDate = (Get-Date "1970-01-01T00:00:00Z" ).ToUniversalTime()
$JWTExpirationTimeSpan = (New-TimeSpan -Start $StartDate -End (Get-Date).ToUniversalTime().AddMinutes(2)).TotalSeconds
$JWTExpiration = [math]::Round($JWTExpirationTimeSpan, 0)
#Create JWT validity start timestamp
$NotBeforeExpirationTimeSpan = (New-TimeSpan -Start $StartDate -End ((Get-Date).ToUniversalTime())).TotalSeconds
$NotBefore = [math]::Round($NotBeforeExpirationTimeSpan, 0)
#Create JWT header
$JWTHeader = @{
alg = "RS256"
typ = "JWT"
#Use the CertificateBase64Hash and replace/strip to match web encoding of base64
x5t = $certificateBase64Hash -replace '\+', '-' -replace '/', '_' -replace '='
}
#Create JWT payload
$JWTPayLoad = @{
#What endpoint is allowed to use this JWT
aud = "https://login.microsoftonline.com/$tenantId/oauth2/token"
#Expiration timestamp
exp = $JWTExpiration
#Issuer = your application
iss = $clientId
#JWT ID: random guid
jti = [guid]::NewGuid()
#Not to be used before
nbf = $NotBefore
#JWT Subject
sub = $clientId
}
#Convert header and payload to base64
$JWTHeaderToByte = [System.Text.Encoding]::UTF8.GetBytes(($JWTHeader | ConvertTo-Json))
$EncodedHeader = [System.Convert]::ToBase64String($JWTHeaderToByte)
$JWTPayLoadToByte = [System.Text.Encoding]::UTF8.GetBytes(($JWTPayload | ConvertTo-Json))
$EncodedPayload = [System.Convert]::ToBase64String($JWTPayLoadToByte)
#Join header and Payload with "." to create a valid (unsigned) JWT
$JWT = $EncodedHeader + "." + $EncodedPayload
#Get the private key object of your certificate
$PrivateKey = $certificate.PrivateKey
#Create a signature of the JWT
$Signature = [Convert]::ToBase64String(
$PrivateKey.SignData([System.Text.Encoding]::UTF8.GetBytes($JWT), [Security.Cryptography.HashAlgorithmName]::SHA256, [Security.Cryptography.RSASignaturePadding]::Pkcs1)
) -replace '\+', '-' -replace '/', '_' -replace '='
#Join the signature to the JWT with "."
$JWT = $JWT + "." + $Signature
#Create a hash with body parameters
$body = @{
client_id = $clientId
client_assertion = $JWT
client_assertion_type = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer"
scope = $scope
grant_type = "client_credentials"
}
#Use the self-generated JWT as Authorization
$headers = @{
Authorization = "Bearer $JWT"
}
#Set the method properties
$requestParams = @{
Method = 'Post'
Uri = "https://login.microsoftonline.com/$tenantId/oauth2/v2.0/token"
Headers = $headers
Body = $body
ContentType = 'application/x-www-form-urlencoded'
ErrorAction = 'Stop'
}
#Request the token
Invoke-RestMethod @requestParams
In preparation I created the Azure AD app Get-PnPAzureADApp | PnP PowerShell. This would create a self-signed certificate, place it in the local User certificate store, create the app in Azure AD, ask for consent for all required permissions and upload the certificate. With a single cmdlet. That’s why I love PnP.PowerShell.
The New-AccessTokenCertificate function would get the certificate from the local user store using the thumbprint and request the accesstoken.
Works on my machine, or at least in VS Code using PowerShell 7, where I wrote the script.
However, a lot of customers that are in the process of migrating data to Microsoft Teams, use migration servers only equipped with PowerShell 5.1.
So obviously, I tested the New-AccessTokenCertificate function within PowerShell ISE. That did not work as expected:
"Exception calling "SignData" with "3" argument(s): "Invalid algorithm specified."
While looking for the cause and solutions, I noticed that a lot of people were struggling with the same issue. After some extensive digging, I found the root cause. PowerShell 5.1 and PowerShell 7 handle certificate private key encryption differently.
PowerShell 5.1 depends on the .Net Framework 4.7 and uses ‘Microsoft Base Cryptographic Provider v1.0’, while PowerShell 7 depends on .Net Core and uses ‘Microsoft Enhanced RSA and AES Cryptographic Provider’.
Create the certificate separately
To solve this, I first separated the creation of a new self-signed certificate when using of the Register-PnPAzureADApp cmdlet. And I used the following cmdlet to generate the certificate:
$cert = New-SelfSignedCertificate -Subject 'MyApp' `
-CertStoreLocation "Cert:\CurrentUser\My" `
-KeyExportPolicy 'Exportable' `
-KeySpec 'Signature' `
-KeyLength 2048 `
-KeyAlgorithm 'RSA' `
-HashAlgorithm 'SHA256' `
-Provider 'Microsoft Enhanced RSA and AES Cryptographic Provider'
The provider parameter is what makes the difference for the issue in this post. Using the ‘Microsoft Enhanced RSA and AES Cryptographic Provider’ ensures compatibility in PowerShell 5.1 and 7.
After that, the certificate is exported:
[SecureString]$pfxPassword = Read-Host -Prompt 'Enter new password new pfx certificate' -AsSecureString
$pfx = Export-PfxCertificate -Cert $cert `
-FilePath "C:\MyApp.pfx" `
-Password $pfxPassword
Next step, would be to create the Azure AD App with a different set of parameters to use the existing pfx:
$app = Register-PnPAzureADApp -ApplicationName 'MyApp' `
-Interactive `
-Tenant "contoso.onmicrosoft.com" `
-GraphApplicationPermissions 'Sites.FullControl.All' `
-SharePointApplicationPermissions 'Sites.FullControl.All' `
-CertificatePath $pfx.FullName `
-CertificatePassword $pfxPassword
After that, requesting the access token in PowerShell 5.1 and VS Code (PowerShell 7) worked without the error. I hope this is useful for anyone struggling with the same issue. I’m confident that at some point PowerShell 7 is also possible for migration servers. But for now (e.g. ShareGate is not compatible with PowerShell 7) this will have to do.