Introduction

The process is quite similar to the client secret flow described here, so be sure to take a look! The challenge here lies in generating a JWT (JSON Web Token) based on a certificate. I will provide a detailed explanation of how the JWT is generated and exchanged for an access token below.

Flow Details

Authentication schema

For more information about this flow be sure to check the previous article. However in short:

  • 1 step to exchange JWT for an access token
  • Non-interactive
  • App Registration has to be created (can’t use first-party MS apps)

Certificate Generation

First, we need a certificate public and private key pair for this to work. Below you can see a small script that generates a self-signed certificate pair inside your user certificate store. Next, we can export the public and private keys using the commands below:

 1$certificateParams = @{
 2    Subject           = "hybridbrothers-demo"
 3    CertStoreLocation = "Cert:\CurrentUser\My"
 4}
 5$certificate = New-SelfSignedCertificate  @certificateParams
 6
 7### Export the public key to be uploaded into your app registration ###
 8Export-Certificate -Cert $certificate -FilePath "public.cer" -Force
 9
10### Export the private key to use to generate a JWT ###
11Export-PfxCertificate -Cert $certificate -FilePath "private.pfx" -Password $password -Force

The private key will be stored in a secrets management solution like Azure Key Vault and the public key will be uploaded in your app registration.

Client Certificate

Code Explanation

A new script has been developed to demonstrate the complex process of generating a JWT token using a private key and then exchanging that token for an access token. Although there are only 2 functions, one of them involves several sequential steps, which are detailed below:

  • PowershellClientCredentialsLogin: This function expects the private key formatted as the following type: “System.Security.Cryptography.X509Certificates.X509Certificate2” You can see in the lower portion how to retrieve that certificate from Azure Key Vault or your local certificate store using Powershell. Next, we have to generate that JWT, because the private key should NEVER be uploaded to Entra ID! When calling the /token endpoint, don’t forget to set these properties:

    • client_assertion_type = “urn:ietf:params:oauth:client-assertion-type:jwt-bearer”
    • client_assertion = “{JWT TOKEN}”
    • grant_type = “client_credentials”
  • JWTTokenRetrieval: This function generates the JWT based on the X509 formatted private key provided by the previous function. Then we take the following steps:

    1. Generate the headers defining the key hash and hashing algorithm.
    2. Generate a payload containing client id, start and end date, audience and a random GUID for good measure
    3. Then we Base64 both the headers and payload and join them together with the “.” as a separator, this will be the raw package
    4. Then we sign the raw package with the private key using SHA512 which gives us the signed package
    5. Last we join the headers and payload from step c and the signed package from step d using again the “.” as a separator which gives us the JWT!

Code

 1function JWTTokenRetrieval {    
 2    param (
 3        [Parameter(Mandatory = $true)][System.Security.Cryptography.X509Certificates.X509Certificate2]$privateCertificate,
 4        [Parameter(Mandatory = $true)][String]$clientId,
 5        [Parameter(Mandatory = $true)][String]$tenantId,
 6        [Parameter(Mandatory = $true)][int]$jwtValidityInSeconds
 7    )
 8
 9    ### JWT Headers ###
10    $headers = @{
11        typ = "JWT"
12        alg = "RS512" 
13        x5t = ([Convert]::ToBase64String($privateCertificate.GetCertHash())).Replace('+', '-').Replace('/', '_')
14    }
15
16    ### JWT Payload ###
17    $payload = @{
18        iss = $clientId
19        sub = $clientId
20        exp = [int][double]::parse((Get-Date -Date $((Get-Date).addSeconds($jwtValidityInSeconds).ToUniversalTime()) -UFormat %s)) 
21        aud = ("https://login.microsoftonline.com/{0}/oauth2/token" -f $tenantId)
22        jti = [guid]::NewGuid()  
23        nbf = [math]::Round((New-TimeSpan -Start (Get-Date) -End ((Get-Date).ToUniversalTime())).TotalSeconds, 0)
24    }
25        
26    ### Parse both headers and payload to JSON and base64 encode ###
27    $headersBase64 = [Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes(($headers | ConvertTo-Json -Compress))).Split('=')[0].Replace('+', '-').Replace('/', '_')
28    $payloadBase64 = [Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes(($payload | ConvertTo-Json -Compress))).Split('=')[0].Replace('+', '-').Replace('/', '_')
29    
30    ### Combine both headers and payload into one object ###
31    $rawPackage = [System.Text.Encoding]::UTF8.GetBytes("$($headersBase64).$($payloadBase64)")
32    
33    ### Sign package with private key of the certificate ###
34    $signedPackage = [Convert]::ToBase64String($privateCertificate.PrivateKey.SignData($rawPackage, [Security.Cryptography.HashAlgorithmName]::SHA512, [Security.Cryptography.RSASignaturePadding]::Pkcs1))
35    
36    ### Combine both headers, payload and signature into one object. This is the JWT ###
37    $token = "$($headersBase64).$($payloadBase64).$($signedPackage)"
38    return $token
39}
40
41function PowershellClientCredentialsLogin {
42    param (
43        [Parameter(Mandatory = $true)][String]$clientId,
44        [Parameter(Mandatory = $true)][String]$tenantId,
45        [Parameter(Mandatory = $true)][System.Security.Cryptography.X509Certificates.X509Certificate2]$privateCertificate,
46        [Parameter(Mandatory = $false)][int]$jwtValidityInSeconds = 30,
47        [Parameter(Mandatory = $false)][String]$scope = 'https://management.azure.com/.default'
48    )
49    ### Static vars ###
50    $tokenUrl = ('https://login.microsoftonline.com/{0}/oauth2/v2.0/token' -f $tenantId)
51
52    ### Build the body for the JWT generation function ###
53    $JWTParams = @{
54        privateCertificate   = $privateCertificate
55        clientId             = $clientId
56        tenantId             = $tenantId
57        jwtValidityInSeconds = $jwtValidityInSeconds
58    }
59        
60    ### Build the body for the clientCertificate flow ###
61    $body = @{
62        client_id             = $clientId
63        tenant                = $tenantId
64        client_assertion_type = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer"
65        client_assertion      = JWTTokenRetrieval @JWTParams
66        grant_type            = "client_credentials"
67        scope                 = $scope
68    }
69
70    ### Call token endpoint to retrieve access token ###
71    $response = Invoke-WebRequest -Method Post -Uri $tokenUrl -Body $body
72    if ($response.StatusCode -eq "200") {
73        $content = ($response.Content | ConvertFrom-Json)
74        return @{
75            access_token = $content.access_token
76        }
77    }
78    else {
79        Write-Warning "Access Code retrieval failed: $($response.StatusCode)"
80    }
81}
82
83### Option A: Retrieve certificate with private key from Azure Key Vault ###
84$keyVaultName = "{KevVaultName}"
85$certificateName = "{CertificateName}"
86$privateCertificate = [Convert]::FromBase64String((Get-AzKeyVaultSecret -VaultName $keyVaultName -Name $certificateName -AsPlainText))
87$privateCertificateObject = New-Object System.Security.Cryptography.X509Certificates.X509Certificate2 -ArgumentList (, $privateCertificate) 
88
89### Option B: Retrieve certificate with private key from file system ###
90$clearPassword = "hybridbrothers.com"
91$privateCertificateObject = New-Object System.Security.Cryptography.X509Certificates.X509Certificate2 -ArgumentList ("private.pfx", $clearPassword)  
92
93### Retrieve access token using client certificate ###
94$tokens = PowershellClientCredentialsLogin -tenantId "{TenantId}" -clientId "{ClientId}" -privateCertificate $privateCertificateObject

Conclusion

I’m happy to be able to understand this authentication flow and it might prove useful as the Connect-MgGraph doesn’t support certificates being fed via a variable. The private key always has to be in your less secure certificate store.