Search code examples
powershellazure-active-directoryjwtmicrosoft-graph-api

JWT valid according to JWT.io but invalid or malformed for Graph API


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?


Solution

  • Regarding the issue, please refer to the following script

    1. Export the certificate as PFX file from your store
    $mypwd = ConvertTo-SecureString -String "1234" -Force -AsPlainText
     $Certificate = Get-Item "Cert:\LocalMachine\My\$CertificateThumbprint"
     $Certificate | Export-PfxCertificate -FilePath C:\mypfx.pfx -Password $mypwd
    
    1. Upload certificate to Azure AD
    $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
    
    1. Create client asseration
    $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
    
    1. Get token
    $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 
    

    enter image description here

    For more details, please refer to

    https://learn.microsoft.com/en-us/azure/active-directory/develop/active-directory-certificate-credentials

    https://learn.microsoft.com/en-us/azure/active-directory/develop/msal-net-client-assertions#signed-assertions


    Update

    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 
    

    enter image description here

    enter image description here