11# Mints a short-lived GitHub App installation access token by signing a JWT
2- # with a private key stored in Azure Key Vault (RSA, RS256). The signed JWT is
3- # exchanged with the GitHub API for a token scoped to a single installation.
2+ # with an RSA private key ( RS256). The signed JWT is exchanged with the GitHub
3+ # API for a token scoped to a single installation.
44#
55# Requirements:
6- # - A GitHub App whose private key has been uploaded into Key Vault as an RSA
7- # key (the PEM converted to a Key Vault *key*, NOT stored as a secret).
8- # - The caller (the federated Azure service connection used to run this script)
9- # must have the `Key Vault Crypto User` role (or at minimum the `Sign`
10- # action) on that key.
6+ # - A GitHub App ID and PEM private key stored as Azure Key Vault secrets.
7+ # - The federated Azure service connection running this script must have
8+ # `Get` access to those two secrets.
119# - The App must be installed on the target organization/account
1210# (`InstallationOwner`) with the permissions/repositories it needs.
1311#
1614
1715[CmdletBinding ()]
1816param (
19- # Name of the Key Vault that holds the GitHub App's RSA signing key .
17+ # Name of the Key Vault holding the GitHub App credentials .
2018 [Parameter (Mandatory = $true )]
2119 [string ] $KeyVaultName ,
2220
23- # Name of the RSA key inside the Key Vault (the App's private key) .
21+ # Secret Manager projection containing the GitHub App ID .
2422 [Parameter (Mandatory = $true )]
25- [string ] $KeyName ,
23+ [string ] $AppIdSecretName ,
2624
27- # The GitHub App's Client ID ( the value to put in the `iss` JWT claim) .
25+ # Secret Manager projection containing the PEM private key .
2826 [Parameter (Mandatory = $true )]
29- [string ] $AppClientId ,
27+ [string ] $AppPrivateKeySecretName ,
3028
3129 # Login of the organization or user account whose installation we should
3230 # mint the token for (e.g. `dotnet`, `microsoft`).
@@ -39,16 +37,69 @@ param(
3937 [Parameter (Mandatory = $false )]
4038 [string ] $OutputVariableName
4139)
42-
4340$ErrorActionPreference = ' Stop'
4441$PSNativeCommandUseErrorActionPreference = $true
4542
4643. $PSScriptRoot \pipeline- logging- functions.ps1
4744
45+ if ($KeyVaultName -notmatch ' ^[A-Za-z][A-Za-z0-9-]{1,22}[A-Za-z0-9]$' -or $KeyVaultName.Contains (' --' )) {
46+ Write-PipelineTelemetryError - Category ' Build' - Message " KeyVaultName '$KeyVaultName ' is not a valid Azure Key Vault name."
47+ exit 1
48+ }
49+
4850function ConvertTo-Base64Url ([byte []] $bytes ) {
4951 return [Convert ]::ToBase64String($bytes ).TrimEnd(' =' ).Replace(' +' , ' -' ).Replace(' /' , ' _' )
5052}
5153
54+ $previousNativeCommandErrorPreference = $PSNativeCommandUseErrorActionPreference
55+ try {
56+ # Azure CLI can emit non-fatal Python warnings to stderr.
57+ $PSNativeCommandUseErrorActionPreference = $false
58+ $keyVaultAccessToken = az account get-access - token `
59+ -- resource https:// vault.azure.net `
60+ -- query accessToken `
61+ -- output tsv `
62+ -- only- show-errors
63+ $tokenExitCode = $LASTEXITCODE
64+ }
65+ catch {
66+ Write-PipelineTelemetryError - Category ' Build' - Message " Failed to acquire an Azure Key Vault access token: $_ "
67+ exit 1
68+ }
69+ finally {
70+ $PSNativeCommandUseErrorActionPreference = $previousNativeCommandErrorPreference
71+ }
72+ if ($tokenExitCode -ne 0 -or [string ]::IsNullOrWhiteSpace($keyVaultAccessToken )) {
73+ Write-PipelineTelemetryError - Category ' Build' - Message " 'az account get-access-token' exited with code $tokenExitCode while acquiring an Azure Key Vault access token."
74+ exit 1
75+ }
76+
77+ function Get-KeyVaultSecret ([string ] $SecretName ) {
78+ # Use the data-plane REST API because `az keyvault secret show` can fail
79+ # with Errno 22 on hosted Windows agents when reading these projections.
80+ $escapedSecretName = [Uri ]::EscapeDataString($SecretName )
81+ $secretUri = " https://$KeyVaultName .vault.azure.net/secrets/$escapedSecretName `?api-version=7.4"
82+ try {
83+ $response = Invoke-RestMethod `
84+ - Uri $secretUri `
85+ - Headers @ { Authorization = " Bearer $keyVaultAccessToken " } `
86+ - Method Get
87+ }
88+ catch {
89+ Write-PipelineTelemetryError - Category ' Build' - Message " Failed to read secret '$SecretName ' from vault '$KeyVaultName ': $_ . Verify the secret exists and the service connection has 'Key Vault Secrets User' access to it."
90+ exit 1
91+ }
92+ if ([string ]::IsNullOrWhiteSpace($response.value )) {
93+ Write-PipelineTelemetryError - Category ' Build' - Message " Secret '$SecretName ' in vault '$KeyVaultName ' is empty."
94+ exit 1
95+ }
96+ return [string ] $response.value
97+ }
98+
99+ Write-Host " Reading GitHub App credentials from vault '$KeyVaultName '..."
100+ $appId = Get-KeyVaultSecret $AppIdSecretName
101+ $privateKey = Get-KeyVaultSecret $AppPrivateKeySecretName
102+
52103# Build JWT header and payload. Use [ordered] hashtables so JSON
53104# serialization is deterministic.
54105$jwtHeader = [ordered ]@ {
@@ -59,46 +110,38 @@ $now = [System.DateTimeOffset]::UtcNow
59110$jwtPayload = [ordered ]@ {
60111 iat = $now.AddMinutes (-1 ).ToUnixTimeSeconds()
61112 exp = $now.AddMinutes (5 ).ToUnixTimeSeconds()
62- iss = $AppClientId
113+ iss = $appId
63114}
64115
65116$headerEncoded = ConvertTo-Base64Url ([System.Text.Encoding ]::UTF8.GetBytes(($jwtHeader | ConvertTo-Json - Compress)))
66117$payloadEncoded = ConvertTo-Base64Url ([System.Text.Encoding ]::UTF8.GetBytes(($jwtPayload | ConvertTo-Json - Compress)))
67118$signingInput = " $headerEncoded .$payloadEncoded "
68119
69- # Key Vault `sign` expects the *digest* (base64), not the raw bytes.
70- $sha256 = [System.Security.Cryptography.SHA256 ]::Create()
71- $digestBytes = $sha256.ComputeHash ([System.Text.Encoding ]::UTF8.GetBytes($signingInput ))
72- $digestBase64 = [Convert ]::ToBase64String($digestBytes )
120+ $sha256 = [System.Security.Cryptography.SHA256 ]::Create()
121+ try {
122+ $digestBytes = $sha256.ComputeHash ([System.Text.Encoding ]::UTF8.GetBytes($signingInput ))
123+ }
124+ finally {
125+ $sha256.Dispose ()
126+ }
73127
74- Write-Host " Signing JWT with key ' $KeyName ' in vault ' $KeyVaultName ' ..."
75- $previousNativeCommandErrorPreference = $PSNativeCommandUseErrorActionPreference
128+ Write-Host ' Signing JWT with the GitHub App private key ...'
129+ $rsa = [ System.Security.Cryptography.RSA ]::Create()
76130try {
77- # Azure CLI can emit non-fatal Python warnings to stderr even when signing succeeds.
78- # Use the exit code to determine success for this invocation.
79- $PSNativeCommandUseErrorActionPreference = $false
80- $signatureBase64 = az keyvault key sign `
81- -- vault- name $KeyVaultName `
82- -- name $KeyName `
83- -- algorithm RS256 `
84- -- digest $digestBase64 `
85- -- query signature `
86- -- output tsv `
87- -- only- show-errors
88- $signExitCode = $LASTEXITCODE
131+ $rsa.ImportFromPem ($privateKey )
132+ $signatureBytes = $rsa.SignHash (
133+ $digestBytes ,
134+ [System.Security.Cryptography.HashAlgorithmName ]::SHA256,
135+ [System.Security.Cryptography.RSASignaturePadding ]::Pkcs1)
136+ $signatureUrl = ConvertTo-Base64Url $signatureBytes
89137}
90138catch {
91- Write-PipelineTelemetryError - Category ' Build' - Message " Failed to sign the JWT via Key Vault (key ' $KeyName ', vault ' $KeyVaultName ') : $_ . Verify the service connection identity has the 'Key Vault Crypto User' role (Sign action) on the key. "
139+ Write-PipelineTelemetryError - Category ' Build' - Message " Failed to sign the GitHub App JWT with the supplied private key : $_ "
92140 exit 1
93141}
94142finally {
95- $PSNativeCommandUseErrorActionPreference = $previousNativeCommandErrorPreference
96- }
97- if ($signExitCode -ne 0 -or [string ]::IsNullOrWhiteSpace($signatureBase64 )) {
98- Write-PipelineTelemetryError - Category ' Build' - Message " 'az keyvault key sign' exited with code $signExitCode for key '$KeyName ' in vault '$KeyVaultName '. Verify the service connection identity has the 'Key Vault Crypto User' role (Sign action) on the key."
99- exit 1
143+ $rsa.Dispose ()
100144}
101- $signatureUrl = $signatureBase64.Trim ().TrimEnd(' =' ).Replace(' +' , ' -' ).Replace(' /' , ' _' )
102145$jwt = " $signingInput .$signatureUrl "
103146
104147$headers = @ {
@@ -126,7 +169,7 @@ try {
126169 } while ($pageInstallationCount -eq 100 )
127170}
128171catch {
129- Write-PipelineTelemetryError - Category ' Build' - Message " Failed to list GitHub App installations: $_ . The signed JWT may be invalid or the App's Client ID (' $AppClientId ') may be incorrect."
172+ Write-PipelineTelemetryError - Category ' Build' - Message " Failed to list GitHub App installations: $_ . The signed JWT may be invalid or the App ID may be incorrect."
130173 exit 1
131174}
132175$matchingInstallations = @ ($installations | Where-Object { $_.account.login -ieq $InstallationOwner })
0 commit comments