I've been working on a script that is creating a JWT for access to Microsoft Graph using a certificate installed locally.
It seems like there is a simpler solution by using the attribute PrivateKey
from the certificate, but in some cases (depending on the key provider CNG or something else) it doesn't show up. I have to manually extract the private key with OpenSSL and then transform it into an RSACryptoServiceProvider
to be able to sign the header+payload.
Now I'm struggling with a JWT token which seems to be valid according to jwt.io but is invalid or malformed for Microsoft.
Here is the error code :
Invoke-RestMethod :
{
"error": "invalid_request",
"error_description": "AADSTS50027: JWT token is invalid or malformed.\r\nTrace ID:ed98bb58-8545-4667-acdd-8ce863303b00\r\nCorrelation ID: 722422ce-48d0-47ae-876d-3fb60a588e75\r\nTimestamp: 2021-02-12 13:53:47Z",
"error_codes": [50027],
"timestamp": "2021-02-12 13:53:47Z",
"trace_id": "ed98bb58-8545-4667-acdd-8ce863303b00",
"correlation_id": "722422ce-48d0-47ae-876d-3fb60a588e75",
"error_uri": "https://login.microsoftonline.com/error?code=50027"
}
At D:\Scripts_Teams\QUAL\ReportingGraphAPI\Get-AuthTokenMSALDelegate.ps1:223 char:20
+ $accessToken = Invoke-RestMethod @PostSplat
+ CategoryInfo : InvalidOperation: (System.Net.HttpWebRequest:HttpWebRequest) [Invoke-RestMethod], WebException
+ FullyQualifiedErrorId : WebCmdletWebResponseException,Microsoft.PowerShell.Commands.InvokeRestMethodCommand ConvertFrom-Json : Cannot bind argument to parameter 'InputObject' because it is null.
+
At D:\Scripts_Teams\QUAL\ReportingGraphAPI\Get-AuthTokenMSALDelegate.ps1:225 char:41
+ $accessToken=$accessToken.content | ConvertFrom-Json
+ CategoryInfo : InvalidData: (:) [ConvertFrom-Json], ParameterBindingValidationException
+ FullyQualifiedErrorId : ParameterArgumentValidationErrorNullNotAllowed,Microsoft.PowerShell.Commands.ConvertFromJsonCommand
Here is the result I have from jwt.io: JWT.IO
And here is the code:
function Generate-JWT (
[Parameter(Mandatory = $True)]
[string]$Issuer = $null,
[Parameter(Mandatory = $True)]
[int]$ValidforMinutes = $null,
[Parameter(Mandatory = $true)]
$CertificateThumbprint,
[Parameter(Mandatory = $true)]
$TenantName
) {
$Certificate = Get-Item "Cert:\LocalMachine\My\$CertificateThumbprint"
# Create base64 hash of certificate
$CertificateBase64Hash = [System.Convert]::ToBase64String($Certificate.GetCertHash())
$tempfile = "C:\Temp\tempCert.pfx"
Export-PfxCertificate -FilePath $tempfile -Cert $Certificate -Password (ConvertTo-SecureString -AsPlainText "MyP@ssw0rd!" -Force)
D:\Tools\OpenSSL\Bin\openssl.exe pkcs12 -in $tempfile -nocerts -out "C:\Temp\key.pem" -nodes -password pass:MyP@ssw0rd!
D:\Tools\OpenSSL\Bin\openssl.exe rsa -in "C:\Temp\key.pem" -out "C:\Temp\key.key"
$Algorithm = 'RS256'
$type = 'JWT'
$x5t = $CertificateBase64Hash -replace '\+', '-' -replace '/', '_' -replace '='
$aud = "https://login.microsoftonline.com/$TenantName/oauth2/token"
$jti = [guid]::NewGuid()
# Create JWT timestamp for expiration
$StartDate = (Get-Date "1970-01-01T00:00:00Z" ).ToUniversalTime()
$JWTExpirationTimeSpan = (New-TimeSpan -Start $StartDate -End (Get-Date).ToUniversalTime().AddMinutes($ValidforMinutes)).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)
[hashtable]$JWTHeader = @{
alg = $Algorithm
typ = $type
x5t = $x5t
}
[hashtable]$JWTPayLoad = @{
aud = $aud
exp = $JWTExpiration
iss = $Issuer
jti = $jti
nbf = $NotBefore
sub = $Issuer
}
$headerjson = $JWTHeader | ConvertTo-Json -Compress
$payloadjson = $JWTPayLoad | ConvertTo-Json -Compress
# Convert header and payload to base64
$EncodedHeader = [Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes($headerjson)).Split('=')[0].Replace('+', '-').Replace('/', '_')
$EncodedPayload = [Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes($payloadjson)).Split('=')[0].Replace('+', '-').Replace('/', '_')
$ToSign = $EncodedHeader + "." + $EncodedPayload
# Define RSA signature and hashing algorithm
$RSAPadding = [Security.Cryptography.RSASignaturePadding]::Pkcs1
$HashAlgorithm = [Security.Cryptography.HashAlgorithmName]::SHA256
$opensslkeysource = Get-Content "D:\Scripts_Teams\QUAL\ReportingGraphAPI\opensslkey.cs" -Raw
try {
Add-Type -TypeDefinition $opensslkeysource
}
catch {
if ($_.Exception -match "already exists") {
Write-Verbose "The JavaScience.Win32 assembly (i.e. opensslkey.cs) is already loaded. Continuing..."
}
}
$PemText = [System.IO.File]::ReadAllText("C:\Temp\key.key")
$PemPrivateKey = [javascience.opensslkey]::DecodeOpenSSLPrivateKey($PemText)
[System.Security.Cryptography.RSACryptoServiceProvider]$RSA = [javascience.opensslkey]::DecodeRSAPrivateKey($PemPrivateKey)
# Create a signature of the JWT
$Signature = [Convert]::ToBase64String(
$RSA.SignData([System.Text.Encoding]::UTF8.GetBytes($ToSign), $HashAlgorithm, $RSAPadding)
) -replace '\+', '-' -replace '/', '_' -replace '='
$JWT = $ToSign + "." + $Signature
return $JWT
}
And here is how it's been called :
$JWT = Generate-JWT -Issuer $clientID -ValidforMinutes 60 -CertificateThumbprint $Thumbprint -TenantName $TenantName
$authBody = @{
client_id = $clientID
client_assertion = $JWT
client_assertion_type = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer"
scope = "https://graph.microsoft.com/.default"
grant_type = "client_credentials"
}
# Use the self-generated JWT as Authorization
$Header = @{
Authorization = "Bearer $JWT"
}
$uri = "https://login.microsoftonline.com/$tenantId/oauth2/v2.0/token"
# Splat the parameters for Invoke-Restmethod for cleaner code
$PostSplat = @{
ContentType = 'application/x-www-form-urlencoded'
Method = 'POST'
Body = $authBody
Uri = $Uri
Headers = $Header
}
$accessToken = Invoke-RestMethod @PostSplat
Any ideas why this JWT would be rejected?
Regarding the issue, please refer to the following script
$mypwd = ConvertTo-SecureString -String "1234" -Force -AsPlainText
$Certificate = Get-Item "Cert:\LocalMachine\My\$CertificateThumbprint"
$Certificate | Export-PfxCertificate -FilePath C:\mypfx.pfx -Password $mypwd
$password= ConvertTo-SecureString "Password0123!" -AsPlainText -Force
$Cert = New-Object System.Security.Cryptography.X509Certificates.X509Certificate2
$Cert.Import("E:\cert\my.pfx", $password, [System.Security.Cryptography.X509Certificates.X509KeyStorageFlags]::Exportable)
Connect-AzureAD
$certKeyId = [Guid]::NewGuid()
$certBase64Value = [System.Convert]::ToBase64String($Cert.GetRawCertData())
$certBase64Thumbprint = [System.Convert]::ToBase64String($Cert.GetCertHash())
# Add a Azure Key Credentials from the certificate for the daemon application
$clientKeyCredentials = New-AzureADApplicationKeyCredential -ObjectId <> `
-CustomKeyIdentifier "" `
-Type AsymmetricX509Cert `
-Usage Verify `
-Value $certBase64Value `
-StartDate $Cert.NotBefore `
-EndDate $Cert.NotAfter
$password= ConvertTo-SecureString "!" -AsPlainText -Force
$Cert = New-Object System.Security.Cryptography.X509Certificates.X509Certificate2
$Cert.Import("E:\cert\my.pfx", $password, [System.Security.Cryptography.X509Certificates.X509KeyStorageFlags]::Exportable)
$appEndPoint = "https://login.microsoftonline.com/<tenantId>/v2.0"
$appClientID = "
<clientid>"
$jwtStartTimeUnix = ([DateTimeOffset](Get-Date).ToUniversalTime()).ToUnixTimeSeconds()
$jwtEndTimeUnix = ([DateTimeOffset](Get-Date).AddHours(1).ToUniversalTime()).ToUnixTimeSeconds()
$jwtID = [guid]::NewGuid().Guid
$headerjson = @{
alg="RS256";
typ="JWT";
x5t=[System.Convert]::ToBase64String($Cert.GetCertHash())
} | ConvertTo-Json -Compress
$payloadjson = @{
aud = $appEndPoint;
exp = $jwtEndTimeUnix;
iss = $appClientID;
jti = $jwtID;
nbf = $jwtStartTimeUnix;
sub = $appClientID
} | ConvertTo-Json -Compress
$EncodedHeader = [Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes($headerjson)).Split('=')[0].Replace('+', '-').Replace('/', '_')
$EncodedPayload = [Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes($payloadjson)).Split('=')[0].Replace('+', '-').Replace('/', '_')
$ToSign = $EncodedHeader + "." + $EncodedPayload
$RSAPadding = [Security.Cryptography.RSASignaturePadding]::Pkcs1
$HashAlgorithm = [Security.Cryptography.HashAlgorithmName]::SHA256
$rsa=[System.Security.Cryptography.RSACryptoServiceProvider]::new()
$rsa.FromXmlString($Cert.PrivateKey.ToXmlString($true))
$Signature=$rsa.SignData([System.Text.Encoding]::UTF8.GetBytes($ToSign), $HashAlgorithm, $RSAPadding)
$Signature = [Convert]::ToBase64String($Signature).Split('=')[0].Replace('+', '-').Replace('/', '_')
$JWT = $ToSign + "." + $Signature
$authBody = @{
client_id = $appClientID
client_assertion = $JWT
client_assertion_type = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer"
scope = "https://graph.microsoft.com/.default"
grant_type = "client_credentials"
}
$uri = "https://login.microsoftonline.com/<>/oauth2/v2.0/token"
# Splat the parameters for Invoke-Restmethod for cleaner code
$PostSplat = @{
ContentType = 'application/x-www-form-urlencoded'
Method = 'POST'
Body = $authBody
Uri = $Uri
}
$accessToken = Invoke-RestMethod @PostSplat
$accessToken
For more details, please refer to
According to my test, the C# code is right. It may your script has some problems. This is my script
$cert = Get-Item "Cert:\LocalMachine\My\<>"
Export-PfxCertificate -FilePath $tempfile -Cert $Certificate -Password (ConvertTo-SecureString -AsPlainText "MyP@ssw0rd!" -Force)
$tempfile=""
D:\Tools\OpenSSL\Bin\openssl.exe pkcs12 -in $tempfile -nocerts -out "E:\cert\key.pem" -nodes -password pass:MyP@ssw0rd!
D:\Tools\OpenSSL\Bin\openssl.exe rsa -in "E:\cert\key.pem" -out "E:\cert\key.key"
$code= Get-Content -Path "D:\opensslkey.cs" -Raw
Add-Type -TypeDefinition $code -Language CSharp
$pemString =Get-Content -Path E:\cert\key.key -Raw
$key=[JavaScience.opensslkey]::DecodeOpenSSLPrivateKey($pemString)
$rsa=[JavaScience.opensslkey]::DecodeRSAPrivateKey($key)
$appEndPoint = "https://login.microsoftonline.com/<tenantId>/oauth2/token"
$appClientID = "232a1406-b27b-4667-b8c2-3a865c42b79c"
$jwtStartTimeUnix = ([DateTimeOffset](Get-Date).ToUniversalTime()).ToUnixTimeSeconds()
$jwtEndTimeUnix = ([DateTimeOffset](Get-Date).AddHours(1).ToUniversalTime()).ToUnixTimeSeconds()
$jwtID = [guid]::NewGuid().Guid
$headerjson = @{
alg="RS256";
typ="JWT";
x5t=[System.Convert]::ToBase64String($Cert.GetCertHash()) -replace '\+', '-' -replace '/', '_' -replace '='
} | ConvertTo-Json -Compress
$payloadjson = @{
aud = $appEndPoint;
exp = $jwtEndTimeUnix;
iss = $appClientID;
jti = $jwtID;
nbf = $jwtStartTimeUnix;
sub = $appClientID
} | ConvertTo-Json -Compress
$EncodedHeader = [Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes($headerjson)).Split('=')[0].Replace('+', '-').Replace('/', '_')
$EncodedPayload = [Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes($payloadjson)).Split('=')[0].Replace('+', '-').Replace('/', '_')
$ToSign = $EncodedHeader + "." + $EncodedPayload
$RSAPadding = [Security.Cryptography.RSASignaturePadding]::Pkcs1
$HashAlgorithm = [Security.Cryptography.HashAlgorithmName]::SHA256
$Signature=$rsa.SignData([System.Text.Encoding]::UTF8.GetBytes($ToSign), $HashAlgorithm, $RSAPadding)
$Signature = [Convert]::ToBase64String($Signature).Split('=')[0].Replace('+', '-').Replace('/', '_')
$JWT = $ToSign + "." + $Signature
$authBody = @{
client_id = $appClientID
client_assertion = $JWT
client_assertion_type = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer"
scope = "https://graph.microsoft.com/.default"
grant_type = "client_credentials"
}
$uri = "https://login.microsoftonline.com/<tenantId>/oauth2/v2.0/token"
# Splat the parameters for Invoke-Restmethod for cleaner code
$PostSplat = @{
ContentType = 'application/x-www-form-urlencoded'
Method = 'POST'
Body = $authBody
Uri = $Uri
}
$accessToken = Invoke-RestMethod @PostSplat
$accessToken.access_token