Skip to content

Commit 465671b

Browse files
🚀 [Feature]: Adding functionality to sign JWTs via Key Vault Keys (#481)
## Description This pull request introduces support for signing GitHub App JSON Web Tokens (JWTs) using Azure Key Vault in addition to local RSA private keys. It also refactors and enhances existing JWT-related functionality to improve maintainability and clarity. The most significant changes include the addition of Azure Key Vault integration, refactoring of JWT signing methods, and updates to related utility functions. - Fixes #470. ### Improvements to Authentication Logic * Enhanced `Connect-GitHubAccount` to support both private key and Azure Key Vault-based authentication for GitHub Apps, introducing new parameter sets and validation for `KeyVaultKeyReference`. ### Azure Key Vault Integration * Added a new `KeyVaultKeyReference` property to the `GitHubAppContext` class for specifying Azure Key Vault keys as an alternative to local private keys. * Introduced the `Add-GitHubKeyVaultJWTSignature` function to sign JWTs using Azure Key Vault keys, supporting both Azure CLI and Az PowerShell authentication. * Added utility functions `Test-GitHubAzureCLI` and `Test-GitHubAzPowerShell` to check for Azure CLI and Az PowerShell module installation and authentication. ### Refactoring of JWT Signing * Renamed `Add-GitHubJWTSignature` to `Add-GitHubLocalJWTSignature` for clarity and updated it to use the new `GitHubJWTComponent` helper for base64 URL encoding. * Updated `Update-GitHubAppJWT` to conditionally use either `Add-GitHubLocalJWTSignature` or `Add-GitHubKeyVaultJWTSignature` based on the presence of `PrivateKey` or `KeyVaultKeyReference` in the context. ### Enhancements to JWT Utility Functions * Added `GitHubJWTComponent` class to centralize base64 URL encoding logic and simplify JWT creation. * Updated `New-GitHubUnsignedJWT` to use `GitHubJWTComponent` for encoding JWT headers and payloads. ## See it in action https://github.com/PSModule/GitHub-Script/actions/runs/16364842244/job/46239654619 ## Type of change <!-- Use the check-boxes [x] on the options that are relevant. --> - [ ] 📖 [Docs] - [ ] 🪲 [Fix] - [ ] 🩹 [Patch] - [ ] ⚠️ [Security fix] - [x] 🚀 [Feature] - [ ] 🌟 [Breaking change] ## Checklist <!-- Use the check-boxes [x] on the options that are relevant. --> - [x] I have performed a self-review of my own code - [x] I have commented my code, particularly in hard-to-understand areas
1 parent 09be1b5 commit 465671b

16 files changed

+504
-92
lines changed

README.md

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,48 @@ Connect-GitHubAccount -ClientID $ClientID -PrivateKey $PrivateKey
154154

155155
Using this approach, the module will autogenerate a JWT every time you run a command. I.e. Get-GitHubApp.
156156

157+
#### Using a GitHub App with Azure Key Vault
158+
159+
For enhanced security, you can store your GitHub App's keys in Azure Key Vault and use that as way to signing the JWTs.
160+
This approach requires a pre-authenticated session with either Azure CLI or Azure PowerShell.
161+
162+
**Prerequisites:**
163+
- Azure CLI authenticated session (`az login`) or Azure PowerShell authenticated session (`Connect-AzAccount`)
164+
- GitHub App private key stored as a key in Azure Key Vault, with 'Sign' as a permitted operation
165+
- Appropriate permissions to read keys from the Key Vault, like 'Key Vault Crypto User'
166+
167+
**Using Azure CLI authentication:**
168+
169+
```powershell
170+
# Ensure you're authenticated with Azure CLI
171+
az login
172+
173+
# Connect using Key Vault key reference (URI with or without version)
174+
Connect-GitHubAccount -ClientID $ClientID -KeyVaultKeyReference 'https://my-keyvault.vault.azure.net/keys/github-app-private-key'
175+
✓ Logged in as my-github-app!
176+
```
177+
178+
**Using Azure PowerShell authentication:**
179+
180+
```powershell
181+
# Ensure you're authenticated with Azure PowerShell
182+
Connect-AzAccount
183+
184+
# Connect using Key Vault key reference (URI with or without version)
185+
Connect-GitHubAccount -ClientID $ClientID -KeyVaultKeyReference 'https://my-keyvault.vault.azure.net/keys/github-app-private-key'
186+
✓ Logged in as my-github-app!
187+
```
188+
189+
**Using Key Vault key reference with version:**
190+
191+
```powershell
192+
# Connect using Key Vault key reference with specific version
193+
Connect-GitHubAccount -ClientID $ClientID -KeyVaultKeyReference 'https://my-keyvault.vault.azure.net/keys/github-app-private-key/abc123def456'
194+
✓ Logged in as my-github-app!
195+
```
196+
197+
This method ensures that your private key is securely stored in Azure Key Vault and never exposed in your scripts or configuration files.
198+
157199
#### Using a different host
158200

159201
If you are using GitHub Enterprise, you can use the `-Host` (or `-HostName`) parameter to specify the host you want to connect to.

src/classes/public/Context/GitHubContext/GitHubAppContext.ps1

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,9 @@
55
# The private key for the app.
66
[securestring] $PrivateKey
77

8+
# Azure Key Vault key reference for JWT signing (alternative to PrivateKey).
9+
[string] $KeyVaultKeyReference
10+
811
# Owner of the GitHub App
912
[string] $OwnerName
1013

@@ -41,6 +44,7 @@
4144
$this.PerPage = $Object.PerPage
4245
$this.ClientID = $Object.ClientID
4346
$this.PrivateKey = $Object.PrivateKey
47+
$this.KeyVaultKeyReference = $Object.KeyVaultKeyReference
4448
$this.OwnerName = $Object.OwnerName
4549
$this.OwnerType = $Object.OwnerType
4650
$this.Permissions = $Object.Permissions
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
class GitHubJWTComponent {
2+
static [string] ToBase64UrlString([hashtable] $Data) {
3+
return [GitHubJWTComponent]::ConvertToBase64UrlFormat(
4+
[System.Convert]::ToBase64String(
5+
[System.Text.Encoding]::UTF8.GetBytes(
6+
(ConvertTo-Json -InputObject $Data)
7+
)
8+
)
9+
)
10+
}
11+
12+
static [string] ConvertToBase64UrlFormat([string] $Base64String) {
13+
return $Base64String.TrimEnd('=').Replace('+', '-').Replace('/', '_')
14+
}
15+
}
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
function Add-GitHubKeyVaultJWTSignature {
2+
<#
3+
.SYNOPSIS
4+
Adds a JWT signature using Azure Key Vault.
5+
6+
.DESCRIPTION
7+
Signs an unsigned JWT (header.payload) using a key stored in Azure Key Vault.
8+
The function supports authentication via Azure CLI or Az PowerShell module and returns the signed JWT as a secure string.
9+
10+
.EXAMPLE
11+
Add-GitHubKeyVaultJWTSignature -UnsignedJWT 'header.payload' -KeyVaultKeyReference 'https://myvault.vault.azure.net/keys/mykey'
12+
13+
Output:
14+
```powershell
15+
System.Security.SecureString
16+
```
17+
18+
Signs the provided JWT (`header.payload`) using the specified Azure Key Vault key, returning a secure string containing the signed JWT.
19+
20+
.OUTPUTS
21+
System.Security.SecureString
22+
23+
.NOTES
24+
The function returns a secure string containing the fully signed JWT (header.payload.signature).
25+
Ensure Azure CLI or Az PowerShell is installed and authenticated before running this function.
26+
#>
27+
[Diagnostics.CodeAnalysis.SuppressMessageAttribute(
28+
'PSAvoidUsingConvertToSecureStringWithPlainText', '',
29+
Justification = 'Used to handle secure string private keys.'
30+
)]
31+
[CmdletBinding()]
32+
param (
33+
# The unsigned JWT (header.payload) to sign.
34+
[Parameter(Mandatory)]
35+
[string] $UnsignedJWT,
36+
37+
# The Azure Key Vault key URL used for signing.
38+
[Parameter(Mandatory)]
39+
[string] $KeyVaultKeyReference
40+
)
41+
42+
begin {
43+
$stackPath = Get-PSCallStackPath
44+
Write-Debug "[$stackPath] - Start"
45+
}
46+
47+
process {
48+
if (Test-GitHubAzureCLI) {
49+
try {
50+
$accessToken = (az account get-access-token --resource 'https://vault.azure.net/' --output json | ConvertFrom-Json).accessToken
51+
} catch {
52+
Write-Error "Failed to get access token from Azure CLI: $_"
53+
return
54+
}
55+
} elseif (Test-GitHubAzPowerShell) {
56+
try {
57+
$accessToken = (Get-AzAccessToken -ResourceUrl 'https://vault.azure.net/').Token
58+
} catch {
59+
Write-Error "Failed to get access token from Az PowerShell: $_"
60+
return
61+
}
62+
} else {
63+
Write-Error 'Azure authentication is required. Please ensure you are logged in using either Azure CLI or Az PowerShell.'
64+
return
65+
}
66+
67+
if ($accessToken -isnot [securestring]) {
68+
$accessToken = ConvertTo-SecureString -String $accessToken -AsPlainText
69+
}
70+
71+
$hash64url = [GitHubJWTComponent]::ConvertToBase64UrlFormat(
72+
[System.Convert]::ToBase64String(
73+
[System.Security.Cryptography.SHA256]::Create().ComputeHash(
74+
[System.Text.Encoding]::UTF8.GetBytes($UnsignedJWT)
75+
)
76+
)
77+
)
78+
79+
$KeyVaultKeyReference = $KeyVaultKeyReference.TrimEnd('/')
80+
81+
$params = @{
82+
Method = 'POST'
83+
URI = "$KeyVaultKeyReference/sign?api-version=7.4"
84+
Body = @{
85+
alg = 'RS256'
86+
value = $hash64url
87+
} | ConvertTo-Json
88+
ContentType = 'application/json'
89+
Authentication = 'Bearer'
90+
Token = $accessToken
91+
}
92+
93+
$result = Invoke-RestMethod @params
94+
$signature = $result.value
95+
return (ConvertTo-SecureString -String "$UnsignedJWT.$signature" -AsPlainText)
96+
}
97+
98+
end {
99+
Write-Debug "[$stackPath] - End"
100+
}
101+
}

src/functions/private/Apps/GitHub Apps/Add-GitHubJWTSignature.ps1 renamed to src/functions/private/Apps/GitHub Apps/Add-GitHubLocalJWTSignature.ps1

Lines changed: 15 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
function Add-GitHubJWTSignature {
1+
function Add-GitHubLocalJWTSignature {
22
<#
33
.SYNOPSIS
44
Signs a JSON Web Token (JWT) using a local RSA private key.
@@ -8,26 +8,25 @@ function Add-GitHubJWTSignature {
88
This function handles the RSA signing process and returns the complete signed JWT.
99
1010
.EXAMPLE
11-
Add-GitHubJWTSignature -UnsignedJWT 'eyJ0eXAiOi...' -PrivateKey '--- BEGIN RSA PRIVATE KEY --- ... --- END RSA PRIVATE KEY ---'
11+
Add-GitHubLocalJWTSignature -UnsignedJWT 'eyJ0eXAiOi...' -PrivateKey '--- BEGIN RSA PRIVATE KEY --- ... --- END RSA PRIVATE KEY ---'
1212
1313
Adds a signature to the unsigned JWT using the provided private key.
1414
1515
.OUTPUTS
16-
String
16+
securestring
1717
1818
.NOTES
1919
This function isolates the signing logic to enable support for multiple signing methods.
2020
2121
.LINK
22-
https://psmodule.io/GitHub/Functions/Apps/GitHub%20App/Add-GitHubJWTSignature
22+
https://psmodule.io/GitHub/Functions/Apps/GitHub%20App/Add-GitHubLocalJWTSignature
2323
#>
2424
[Diagnostics.CodeAnalysis.SuppressMessageAttribute(
25-
'PSAvoidUsingConvertToSecureStringWithPlainText',
26-
'',
25+
'PSAvoidUsingConvertToSecureStringWithPlainText', '',
2726
Justification = 'Used to handle secure string private keys.'
2827
)]
2928
[CmdletBinding()]
30-
[OutputType([string])]
29+
[OutputType([securestring])]
3130
param(
3231
# The unsigned JWT (header.payload) to sign.
3332
[Parameter(Mandatory)]
@@ -52,14 +51,16 @@ function Add-GitHubJWTSignature {
5251
$rsa.ImportFromPem($PrivateKey)
5352

5453
try {
55-
$signature = [Convert]::ToBase64String(
56-
$rsa.SignData(
57-
[System.Text.Encoding]::UTF8.GetBytes($UnsignedJWT),
58-
[System.Security.Cryptography.HashAlgorithmName]::SHA256,
59-
[System.Security.Cryptography.RSASignaturePadding]::Pkcs1
54+
$signature = [GitHubJWTComponent]::ConvertToBase64UrlFormat(
55+
[System.Convert]::ToBase64String(
56+
$rsa.SignData(
57+
[System.Text.Encoding]::UTF8.GetBytes($UnsignedJWT),
58+
[System.Security.Cryptography.HashAlgorithmName]::SHA256,
59+
[System.Security.Cryptography.RSASignaturePadding]::Pkcs1
60+
)
6061
)
61-
).TrimEnd('=').Replace('+', '-').Replace('/', '_')
62-
return "$UnsignedJWT.$signature"
62+
)
63+
return (ConvertTo-SecureString -String "$UnsignedJWT.$signature" -AsPlainText)
6364
} finally {
6465
if ($rsa) {
6566
$rsa.Dispose()

src/functions/private/Apps/GitHub Apps/New-GitHubUnsignedJWT.ps1

Lines changed: 14 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
function New-GitHubUnsignedJWT {
1+
function New-GitHubUnsignedJWT {
22
<#
33
.SYNOPSIS
44
Creates an unsigned JSON Web Token (JWT) for a GitHub App.
@@ -38,30 +38,22 @@ function New-GitHubUnsignedJWT {
3838
}
3939

4040
process {
41-
$header = [Convert]::ToBase64String(
42-
[System.Text.Encoding]::UTF8.GetBytes(
43-
(
44-
ConvertTo-Json -InputObject @{
45-
alg = 'RS256'
46-
typ = 'JWT'
47-
}
48-
)
49-
)
50-
).TrimEnd('=').Replace('+', '-').Replace('/', '_')
41+
$header = [GitHubJWTComponent]::ToBase64UrlString(
42+
@{
43+
alg = 'RS256'
44+
typ = 'JWT'
45+
}
46+
)
5147
$now = [System.DateTimeOffset]::UtcNow
5248
$iat = $now.AddSeconds(-$script:GitHub.Config.JwtTimeTolerance)
5349
$exp = $now.AddSeconds($script:GitHub.Config.JwtTimeTolerance)
54-
$payload = [Convert]::ToBase64String(
55-
[System.Text.Encoding]::UTF8.GetBytes(
56-
(
57-
ConvertTo-Json -InputObject @{
58-
iat = $iat.ToUnixTimeSeconds()
59-
exp = $exp.ToUnixTimeSeconds()
60-
iss = $ClientID
61-
}
62-
)
63-
)
64-
).TrimEnd('=').Replace('+', '-').Replace('/', '_')
50+
$payload = [GitHubJWTComponent]::ToBase64UrlString(
51+
@{
52+
iat = $iat.ToUnixTimeSeconds()
53+
exp = $exp.ToUnixTimeSeconds()
54+
iss = $ClientID
55+
}
56+
)
6557
[pscustomobject]@{
6658
Base = "$header.$payload"
6759
IssuedAt = $iat.DateTime
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
function Test-GitHubJWTRefreshRequired {
2+
<#
3+
.SYNOPSIS
4+
Test if the GitHub JWT should be refreshed.
5+
6+
.DESCRIPTION
7+
Test if the GitHub JWT should be refreshed. JWTs are refreshed when they have 150 seconds or less remaining before expiration.
8+
9+
.EXAMPLE
10+
Test-GitHubJWTRefreshRequired -Context $Context
11+
12+
This will test if the GitHub JWT should be refreshed for the specified context.
13+
14+
.NOTES
15+
JWTs are short-lived tokens (typically 10 minutes) and need to be refreshed more frequently than user access tokens.
16+
The refresh threshold is set to 150 seconds (2.5 minutes) to ensure the JWT doesn't expire during API operations.
17+
#>
18+
[OutputType([bool])]
19+
[CmdletBinding()]
20+
param(
21+
# The context to run the command in. Used to get the details for the API call.
22+
# Can be either a string or a GitHubContext object.
23+
[Parameter(Mandatory)]
24+
[object] $Context
25+
)
26+
27+
begin {
28+
$stackPath = Get-PSCallStackPath
29+
Write-Debug "[$stackPath] - Start"
30+
}
31+
32+
process {
33+
try {
34+
($Context.TokenExpiresAt - [datetime]::Now).TotalSeconds -le ($script:GitHub.Config.JwtTimeTolerance / 2)
35+
} catch {
36+
return $true
37+
}
38+
}
39+
40+
end {
41+
Write-Debug "[$stackPath] - End"
42+
}
43+
}

0 commit comments

Comments
 (0)