Skip to content

Commit af6312d

Browse files
ci: authenticate weekly generation pipelines with GitHub App instead of PAT
Mint a short-lived GitHub App installation token from Key Vault (akv-prod-eastus secrets microsoft-graph-devx-bot-appid / -privatekey via the "Federated AKV Managed Identity Connection") and use it for git push and gh pr create, replacing the PAT GITHUB_TOKEN in the weekly-generation, weekly-examples-update, and command-metadata-refresh pipelines. Adds scripts/Generate-Github-Token.ps1 and the reusable common-templates/get-github-app-token.yml, inserted before every push/PR step. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: b1e4dbfd-ffcd-4a3e-bd35-1632b2912252
1 parent b3d0ce5 commit af6312d

7 files changed

Lines changed: 266 additions & 0 deletions

File tree

.azure-pipelines/command-metadata-refresh.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,7 @@ extends:
137137
targetType: inline
138138
script: |
139139
. "$(System.DefaultWorkingDirectory)\tools\Versions\BumpModuleVersion.ps1" -BumpV1Module -BumpBetaModule -BumpAuthModule -Debug
140+
- template: .azure-pipelines/common-templates/get-github-app-token.yml@self
140141
- task: Bash@3
141142
displayName: Push version bump changes
142143
env:

.azure-pipelines/common-templates/create-pr.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ parameters:
1616
default: ""
1717

1818
steps:
19+
- template: ./get-github-app-token.yml
20+
1921
- task: PowerShell@2
2022
displayName: Create Pull Request for generated build
2123
env:

.azure-pipelines/common-templates/download-openapi-docs.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,8 @@ steps:
7878
script: dotnet run
7979
workingDirectory: "$(System.DefaultWorkingDirectory)/tools/OpenApiInfoGenerator/OpenApiInfoGenerator"
8080

81+
- template: ./get-github-app-token.yml
82+
8183
- task: Bash@3
8284
displayName: Commit downloaded files
8385
condition: and(succeeded(), ne(variables['ModuleGenerationList'], ''))
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
# Copyright (c) Microsoft Corporation. All rights reserved.
2+
# Licensed under the MIT License.
3+
4+
# Fetches the Microsoft Graph DevX GitHub App credentials from Key Vault and mints a short-lived
5+
# installation access token, exposing it to subsequent steps as the secret variable GITHUB_TOKEN.
6+
#
7+
# Include this template immediately before any step that pushes to GitHub or creates a pull
8+
# request. Downstream steps continue to reference $(GITHUB_TOKEN) exactly as before, but the value
9+
# is now a GitHub App installation token instead of a long-lived PAT. Because installation tokens
10+
# expire after ~1 hour, generate the token right before it is used rather than once per job.
11+
12+
parameters:
13+
- name: RepoName
14+
type: string
15+
default: microsoftgraph/msgraph-sdk-powershell
16+
17+
steps:
18+
- task: AzureKeyVault@2
19+
displayName: "Azure Key Vault: Get GitHub App secrets"
20+
inputs:
21+
azureSubscription: "Federated AKV Managed Identity Connection"
22+
KeyVaultName: akv-prod-eastus
23+
SecretsFilter: "microsoft-graph-devx-bot-appid,microsoft-graph-devx-bot-privatekey"
24+
25+
- task: PowerShell@2
26+
displayName: "Generate GitHub App installation token"
27+
env:
28+
GhAppId: $(microsoft-graph-devx-bot-appid)
29+
GhAppKey: $(microsoft-graph-devx-bot-privatekey)
30+
inputs:
31+
pwsh: true
32+
targetType: inline
33+
errorActionPreference: stop
34+
script: |
35+
$token = & "$(System.DefaultWorkingDirectory)/scripts/Generate-Github-Token.ps1" `
36+
-AppClientId $env:GhAppId `
37+
-AppPrivateKeyContents $env:GhAppKey `
38+
-Repository "${{ parameters.RepoName }}"
39+
if ([string]::IsNullOrWhiteSpace($token)) {
40+
throw "Failed to generate GitHub App installation token (empty result)."
41+
}
42+
# Mask the token so it can never surface in pipeline logs, then expose it to later steps.
43+
Write-Host "##vso[task.setsecret]$token"
44+
Write-Host "##vso[task.setvariable variable=GITHUB_TOKEN;issecret=true]$token"
45+
Write-Host "Generated GitHub App installation token and set GITHUB_TOKEN."

.azure-pipelines/generation-templates/generate-command-metadata.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ steps:
1717
script: |
1818
. $(System.DefaultWorkingDirectory)/tools/PostGeneration/AuthModuleMetadata.ps1
1919
20+
- template: ../common-templates/get-github-app-token.yml
21+
2022
- task: Bash@3
2123
displayName: Push command metadata
2224
enabled: true

.azure-pipelines/weekly-examples-update.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,7 @@ extends:
8282
targetType: 'filePath'
8383
pwsh: true
8484
filePath: tools\ImportExamples.ps1
85+
- template: .azure-pipelines/common-templates/get-github-app-token.yml@self
8586
- task: PowerShell@2
8687
displayName: Pushing to github
8788
env:

scripts/Generate-Github-Token.ps1

Lines changed: 213 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,213 @@
1+
# Copyright (c) Microsoft Corporation. All rights reserved.
2+
# Licensed under the MIT License.
3+
4+
# Generates a short-lived GitHub App installation access token for the specified repository.
5+
#
6+
# The token is minted by:
7+
# 1. Building an RS256-signed JWT from the App's client id and private key.
8+
# 2. Resolving the App installation id for the owner (org, then repo, then user scope).
9+
# 3. Exchanging the JWT for a repository-scoped installation access token.
10+
#
11+
# The resulting token is valid for ~1 hour and should be generated immediately before it is
12+
# used (e.g. right before a git push or PR creation). Emitted on the pipeline via Write-Output.
13+
14+
[CmdletBinding()]
15+
param (
16+
[Parameter(Mandatory = $true)]
17+
[string]
18+
$AppClientId,
19+
[Parameter(Mandatory = $true)]
20+
[string]
21+
$AppPrivateKeyContents,
22+
[Parameter(Mandatory = $true)]
23+
[ValidatePattern('^[a-zA-Z0-9_.-]+/[a-zA-Z0-9_.-]+$', ErrorMessage = "Repository must be in the format 'owner/repo' (e.g. 'octocat/hello-world')")]
24+
[string]
25+
$Repository
26+
)
27+
28+
$ErrorActionPreference = "Stop"
29+
30+
function Generate-AppToken {
31+
param (
32+
[string]
33+
$ClientId,
34+
[string]
35+
$PrivateKeyContents
36+
)
37+
38+
$header = [Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes((ConvertTo-Json -InputObject @{
39+
alg = "RS256"
40+
typ = "JWT"
41+
}))).TrimEnd('=').Replace('+', '-').Replace('/', '_');
42+
43+
$payload = [Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes((ConvertTo-Json -InputObject @{
44+
iat = [System.DateTimeOffset]::UtcNow.AddSeconds(-10).ToUnixTimeSeconds()
45+
exp = [System.DateTimeOffset]::UtcNow.AddMinutes(1).ToUnixTimeSeconds()
46+
iss = $ClientId
47+
}))).TrimEnd('=').Replace('+', '-').Replace('/', '_');
48+
49+
$rsa = [System.Security.Cryptography.RSA]::Create()
50+
$rsa.ImportFromPem($PrivateKeyContents)
51+
52+
$signature = [Convert]::ToBase64String($rsa.SignData([System.Text.Encoding]::UTF8.GetBytes("$header.$payload"), [System.Security.Cryptography.HashAlgorithmName]::SHA256, [System.Security.Cryptography.RSASignaturePadding]::Pkcs1)).TrimEnd('=').Replace('+', '-').Replace('/', '_')
53+
$jwt = "$header.$payload.$signature"
54+
55+
return $jwt
56+
}
57+
58+
function Generate-InstallationToken {
59+
param (
60+
[string]
61+
$AppToken,
62+
[string]
63+
$InstallationId,
64+
[string]
65+
$Repository
66+
)
67+
68+
$uri = "https://api.github.com/app/installations/$InstallationId/access_tokens"
69+
$headers = @{
70+
Authorization = "Bearer $AppToken"
71+
Accept = "application/vnd.github+json"
72+
"X-GitHub-Api-Version" = "2022-11-28"
73+
}
74+
75+
$body = @{
76+
repositories = @($Repository)
77+
}
78+
79+
$response = Invoke-RestMethod -Uri $uri -Method Post -Headers $headers -Body (ConvertTo-Json -InputObject $body -Compress -Depth 10)
80+
81+
return $response.token
82+
}
83+
84+
function Get-OrganizationInstallationId {
85+
param (
86+
[string]
87+
$AppToken,
88+
[string]
89+
$Organization
90+
)
91+
92+
$uri = "https://api.github.com/orgs/$Organization/installation"
93+
$headers = @{
94+
Authorization = "Bearer $AppToken"
95+
Accept = "application/vnd.github+json"
96+
"X-GitHub-Api-Version" = "2022-11-28"
97+
}
98+
99+
try {
100+
$response = Invoke-RestMethod -Uri $uri -Method Get -Headers $headers
101+
102+
return $response.id
103+
}
104+
catch [Microsoft.PowerShell.Commands.HttpResponseException] {
105+
if ($_.Exception.Response.StatusCode -eq [System.Net.HttpStatusCode]::UnprocessableContent -or $_.Exception.Response.StatusCode -eq [System.Net.HttpStatusCode]::NotFound) {
106+
return $null
107+
}
108+
109+
throw
110+
}
111+
}
112+
113+
function Get-RepositoryInstallationId {
114+
param (
115+
[string]
116+
$AppToken,
117+
[string]
118+
$Repository
119+
)
120+
121+
$uri = "https://api.github.com/repos/$Repository/installation"
122+
$headers = @{
123+
Authorization = "Bearer $AppToken"
124+
Accept = "application/vnd.github+json"
125+
"X-GitHub-Api-Version" = "2022-11-28"
126+
}
127+
128+
try {
129+
$response = Invoke-RestMethod -Uri $uri -Method Get -Headers $headers
130+
131+
return $response.id
132+
}
133+
catch [Microsoft.PowerShell.Commands.HttpResponseException] {
134+
if ($_.Exception.Response.StatusCode -eq [System.Net.HttpStatusCode]::UnprocessableContent -or $_.Exception.Response.StatusCode -eq [System.Net.HttpStatusCode]::NotFound) {
135+
return $null
136+
}
137+
138+
throw
139+
}
140+
}
141+
142+
function Get-UserInstallationId {
143+
param (
144+
[string]
145+
$AppToken,
146+
[string]
147+
$Username
148+
)
149+
150+
$uri = "https://api.github.com/users/$Username/installation"
151+
$headers = @{
152+
Authorization = "Bearer $AppToken"
153+
Accept = "application/vnd.github+json"
154+
"X-GitHub-Api-Version" = "2022-11-28"
155+
}
156+
157+
try {
158+
$response = Invoke-RestMethod -Uri $uri -Method Get -Headers $headers
159+
160+
return $response.id
161+
}
162+
catch [Microsoft.PowerShell.Commands.HttpResponseException] {
163+
if ($_.Exception.Response.StatusCode -eq [System.Net.HttpStatusCode]::UnprocessableContent -or $_.Exception.Response.StatusCode -eq [System.Net.HttpStatusCode]::NotFound) {
164+
return $null
165+
}
166+
167+
throw
168+
}
169+
}
170+
171+
function Get-InstallationId {
172+
param (
173+
[string]
174+
$AppToken,
175+
[string]
176+
$Owner,
177+
[string]
178+
$Repo
179+
)
180+
181+
$orgInstallationId = Get-OrganizationInstallationId -AppToken $AppToken -Organization $Owner
182+
183+
if ($null -eq $orgInstallationId) {
184+
$repoInstallationId = Get-RepositoryInstallationId -AppToken $AppToken -Repository "$Owner/$Repo"
185+
}
186+
else {
187+
return $orgInstallationId
188+
}
189+
190+
if ($null -eq $repoInstallationId) {
191+
$userInstallationId = Get-UserInstallationId -AppToken $AppToken -Username $Owner
192+
}
193+
else {
194+
return $repoInstallationId
195+
}
196+
197+
if ($null -eq $userInstallationId) {
198+
throw "Installation not found for repository '$Repo'"
199+
}
200+
else {
201+
return $userInstallationId
202+
}
203+
}
204+
205+
$owner, $repo = $Repository -split '/'
206+
207+
$AppToken = Generate-AppToken -ClientId $AppClientId -PrivateKeyContents $AppPrivateKeyContents
208+
209+
$InstallationId = Get-InstallationId -AppToken $AppToken -Owner $owner -Repo $repo
210+
211+
$InstallationToken = Generate-InstallationToken -AppToken $AppToken -InstallationId $InstallationId -Repository $repo
212+
213+
Write-Output $InstallationToken

0 commit comments

Comments
 (0)