Azure and DevOops

Signing your JWTs for your GitHub Apps using Azure KeyVault

ยท Christian Piet

GitHub Apps

In a previous post about GitHub Apps, I demonstrated how to use a PowerShell module to create your JWTs and interact with GitHub’s REST APIs. I don’t like storing the private key on the server, so in this post, I’ll show you how to use Azure Key Vault’s signing API to sign your JWT.

We want to get the same results, but without the private key on our host locally.

Getting content with a GitHub App

Azure KeyVault

Since the Azure Functions host, in our scenario (HTTP-triggered), is a public web server, I strongly disliked storing my private key file locally on that host’s file system. We typically store our private keys in a Microsoft-hosted secret vault, specifically the Azure Key Vault service. I investigated whether I could use Azure Key Vault for this use case.

Unfortunately, PowerShell-JWT doesn’t support creating an unsigned JWT, so we’ll have to make our tokens ourselves. JWTs are just JSON’s base64 URL encoded, so it’s base64 with a few characters replaced.

 1function New-UnsignedJWT {
 2       [CmdletBinding(SupportsShouldProcess)]
 3       param (
 4              # JWT Header (https://www.rfc-editor.org/rfc/rfc7519#page-11), always creates JWT typ and uses RS256 signing algorith.
 5              [Parameter()]
 6              [Hashtable]
 7              $Headers,
 8              # Payload with the JWT claims (https://www.rfc-editor.org/rfc/rfc7519#page-8), always adds iat and exp by default.
 9              [Parameter()]
10              [Hashtable]
11              $Payload
12       )
13
14       if ($PSCmdlet.ShouldProcess('New UnsignedJWT')) {
15              $jwtHeader = @{
16                     alg = 'RS256'
17                     typ = 'JWT'
18              }
19
20              $unformattedHeader = if ($Headers) {
21                     $Headers + $jwtHeader
22              } else {
23                     $jwtHeader
24              }
25
26              $formattedHeader = $unformattedHeader | ConvertTo-Json -Compress
27
28              $jwtPayload = @{
29                     'iat' = [Int32](Get-Date -UFormat %s) - 60
30                     'exp' = [Int32](Get-Date (Get-Date).AddMinutes(8) -UFormat %s)
31              }
32
33              $unformattedPayload = if ($Payload) {
34                     $Payload + $jwtPayload
35              } else {
36                     $jwtPayload
37              }
38
39              $formattedPayload = $unformattedPayload | ConvertTo-Json -Compress -Depth 99
40
41              '{0}.{1}' -f @(
42                     [Convert]::ToBase64String([System.Text.UTF8Encoding]::UTF8.GetBytes($formattedHeader)).TrimEnd('=').Replace('+', '-').Replace('/', '_'),
43                     [Convert]::ToBase64String([System.Text.UTF8Encoding]::UTF8.GetBytes($formattedPayload)).TrimEnd('=').Replace('+', '-').Replace('/', '_')
44              )
45       }
46}

After much tinkering with Base64 URL-encoded strings and finally overcoming the last hurdle with the hash, thanks again to Drew from the PowerShell Discord, I generated a valid JWT signature using this code. Make sure you’re logged into Azure PowerShell and that the identity performing the script has the Key-signing permission on the key vault containing the private key.

 1function New-AZKVTokenSignature {
 2       [CmdletBinding(SupportsShouldProcess)]
 3       param (
 4              [Parameter(Mandatory)]
 5              [String]
 6              $JWT,
 7              [Parameter()]
 8              [String]
 9              $KeyVaultName,
10              [Parameter()]
11              [String]
12              $PrivateKeyName,
13              [Parameter()]
14              [String]
15              $PrivateKeyVersion
16       )
17       if ($PSCmdlet.ShouldProcess('Requesting KeyVault API JWT signature')) {
18              try {
19                     $KeyVaultToken = Get-AzAccessToken -ResourceTypeName KeyVault
20                     if (!$KeyVaultToken) {
21                            throw 'Could not get KeyVault token'
22                     }
23              } catch {
24                     throw $_
25              }
26       }
27       $JwsResultAsByteArr = [System.Text.Encoding]::UTF8.GetBytes($JWT)
28
29       # Signing requires the hash of the JWT at this point (which should include the header)
30       $hash = [System.Security.Cryptography.SHA256]::Create().ComputeHash($JwsResultAsByteArr)
31
32       $hash64 = [Convert]::ToBase64String($hash)
33
34       if ($PSCmdlet.ShouldProcess($KeyVaultName, "Requesting JWT signing operation from KeyVault with $PrivateKeyName")) {
35              $irmSplat = @{
36                     Method         = 'Post'
37                     Authentication = 'Bearer'
38                     Token          = $KeyVaultToken.Token
39                     Body           = @{
40                            alg   = 'RS256'
41                            value = $hash64
42                     } | ConvertTo-Json
43                     ContentType    = 'application/json'
44                     Uri            = "https://$KeyVaultName.vault.azure.net/keys/$PrivateKeyName/$PrivateKeyVersion/sign?api-version=7.4"
45              }
46
47              try {
48                     $signature = Invoke-RestMethod @irmSplat
49                     if (!$signature) {
50                            throw 'Could not get signature from KeyVault'
51                     }
52              } catch {
53                     throw $_
54              }
55       }
56       '{0}.{1}' -f $JWT, $signature.value
57}
58$newAZKVTokenSignatureSplat = @{
59       JWT               = (New-UnsignedJWT -Payload @{iss = 1459729 })
60       KeyVaultName      = 'd826699b15174c50b627616'
61       PrivateKeyName    = 'githubkey'
62       PrivateKeyVersion = 'cf96e6ec6f104f71882b5309f718c6e2'
63}
64
65$JWT = New-AZKVTokenSignature @newAZKVTokenSignatureSplat

With this JWT I could call my installations and retrieve a short-lived installation token. AWESOME!

 1function New-GHRepoToken {
 2       [CmdletBinding(SupportsShouldProcess)]
 3       param (
 4              [Parameter()]
 5              [String]
 6              $JWT
 7       )
 8
 9       if ($PSCmdlet.ShouldProcess('Requesting GitHub App Installations using Signed JWT Token')) {
10
11              $invokeRestMethodSplat = @{
12                     Uri     = 'https://api.github.com/app/installations' # This gets all installations and should just be used cross organizations.
13                     Headers = @{
14                            Accept                 = 'application/vnd.github+json'
15                            Authorization          = "Bearer $JWT"
16                            'X-GitHub-Api-Version' = '2022-11-28'
17                     }
18              }
19              try {
20                     $Installations = Invoke-RestMethod @invokeRestMethodSplat
21                     if (!$Installations) {
22                            throw 'Could not get installations with JWT'
23                     }
24              } catch {
25                     $PSCmdlet.ThrowTerminatingError($_)
26              }
27       }
28
29       $AccessTokensUrl = ($Installations).access_tokens_url # This assumes there is only 1 installation
30
31       if ($PSCmdlet.ShouldProcess('Requesting GitHub Repo token using JWT')) {
32
33              $irmSplat = @{
34                     Uri     = $AccessTokensUrl
35                     Method  = 'Post'
36                     Headers = @{
37                            Accept                 = 'application/vnd.github+json'
38                            Authorization          = "Bearer $JWT"
39                            'X-GitHub-Api-Version' = '2022-11-28'
40                     }
41              }
42              try {
43                     $outputtoken = Invoke-RestMethod @irmSplat
44                     if (!$outputtoken) {
45                            throw 'Could not get GitHub Repo token'
46                     }
47              } catch {
48                     $PSCmdlet.ThrowTerminatingError($_)
49              }
50       }
51       $outputtoken # for reflection, this has the token as plaintext as a property
52}
53
54$installationToken = New-GHRepoToken -JWT $JWT
55
56$Organization = 'Manbearpiet'
57$RepositoryName = 'Manbearpiet' # Public repo
58$Path = 'README.md'
59
60$file = @{
61       Uri     = "https://api.github.com/repos/$Organization/$RepositoryName/contents/$Path"
62       Method  = 'Get'
63       Headers = @{
64              Accept                 = 'application/vnd.github+json'
65              Authorization          = "Bearer $($installationToken.token)"
66              'X-GitHub-Api-Version' = '2022-11-28'
67       }
68}
69Invoke-RestMethod @file
70
71$Organization = 'Manbearpiet'
72$RepositoryName = 'ManbearpietPrivate' # Private repo
73$file = @{
74       Uri     = "https://api.github.com/repos/$Organization/$RepositoryName/contents/$Path"
75       Method  = 'Get'
76       Headers = @{
77              Accept                 = 'application/vnd.github+json'
78              Authorization          = "Bearer $($installationToken.token)"
79              'X-GitHub-Api-Version' = '2022-11-28'
80       }
81}
82
83Invoke-RestMethod @file
A successful get of the repository content using the Azure Key Vault Signed JWT

Conclusion

Using GitHub Apps, we have an identity that allows us to automate actions on the GitHub platform. Because we’re using Azure Functions as a platform, I don’t feel comfortable storing the private key of my GitHub App on the Azure Functions Host. Therefore, I used the Key Vault Signing REST API, and I can sleep better at night. It’ll make implementations for GitHub Apps or other identity platforms with JWT signing more secure.

#github #github apps #tokens #azure key vault