Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .azure-pipelines/command-metadata-refresh.yml
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,7 @@ extends:
targetType: inline
script: |
. "$(System.DefaultWorkingDirectory)\tools\Versions\BumpModuleVersion.ps1" -BumpV1Module -BumpBetaModule -BumpAuthModule -Debug
- template: .azure-pipelines/common-templates/get-github-app-token.yml@self
- task: Bash@3
displayName: Push version bump changes
env:
Expand Down
2 changes: 2 additions & 0 deletions .azure-pipelines/common-templates/create-pr.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ parameters:
default: ""

steps:
- template: ./get-github-app-token.yml

- task: PowerShell@2
displayName: Create Pull Request for generated build
env:
Expand Down
2 changes: 2 additions & 0 deletions .azure-pipelines/common-templates/download-openapi-docs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,8 @@ steps:
script: dotnet run
workingDirectory: "$(System.DefaultWorkingDirectory)/tools/OpenApiInfoGenerator/OpenApiInfoGenerator"

- template: ./get-github-app-token.yml

- task: Bash@3
displayName: Commit downloaded files
condition: and(succeeded(), ne(variables['ModuleGenerationList'], ''))
Expand Down
45 changes: 45 additions & 0 deletions .azure-pipelines/common-templates/get-github-app-token.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
# Copyright (c) Microsoft Corporation. All rights reserved.
# Licensed under the MIT License.

# Fetches the Microsoft Graph DevX GitHub App credentials from Key Vault and mints a short-lived
# installation access token, exposing it to subsequent steps as the secret variable GITHUB_TOKEN.
#
# Include this template immediately before any step that pushes to GitHub or creates a pull
# request. Downstream steps continue to reference $(GITHUB_TOKEN) exactly as before, but the value
# is now a GitHub App installation token instead of a long-lived PAT. Because installation tokens
# expire after ~1 hour, generate the token right before it is used rather than once per job.

parameters:
- name: RepoName
type: string
default: microsoftgraph/msgraph-sdk-powershell

steps:
- task: AzureKeyVault@2
displayName: "Azure Key Vault: Get GitHub App secrets"
inputs:
azureSubscription: "Federated AKV Managed Identity Connection"
KeyVaultName: akv-prod-eastus
SecretsFilter: "microsoft-graph-devx-bot-appid,microsoft-graph-devx-bot-privatekey"

- task: PowerShell@2
displayName: "Generate GitHub App installation token"
env:
GhAppId: $(microsoft-graph-devx-bot-appid)
GhAppKey: $(microsoft-graph-devx-bot-privatekey)
inputs:
pwsh: true
targetType: inline
errorActionPreference: stop
script: |
$token = & "$(System.DefaultWorkingDirectory)/scripts/Generate-Github-Token.ps1" `
-AppClientId $env:GhAppId `
-AppPrivateKeyContents $env:GhAppKey `
-Repository "${{ parameters.RepoName }}"
if ([string]::IsNullOrWhiteSpace($token)) {
throw "Failed to generate GitHub App installation token (empty result)."
}
# Mask the token so it can never surface in pipeline logs, then expose it to later steps.
Write-Host "##vso[task.setsecret]$token"
Write-Host "##vso[task.setvariable variable=GITHUB_TOKEN;issecret=true]$token"
Write-Host "Generated GitHub App installation token and set GITHUB_TOKEN."
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ steps:
script: |
. $(System.DefaultWorkingDirectory)/tools/PostGeneration/AuthModuleMetadata.ps1

- template: ../common-templates/get-github-app-token.yml

- task: Bash@3
displayName: Push command metadata
enabled: true
Expand Down
1 change: 1 addition & 0 deletions .azure-pipelines/weekly-examples-update.yml
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ extends:
targetType: 'filePath'
pwsh: true
filePath: tools\ImportExamples.ps1
- template: .azure-pipelines/common-templates/get-github-app-token.yml@self
- task: PowerShell@2
displayName: Pushing to github
env:
Expand Down
213 changes: 213 additions & 0 deletions scripts/Generate-Github-Token.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,213 @@
# Copyright (c) Microsoft Corporation. All rights reserved.
# Licensed under the MIT License.

# Generates a short-lived GitHub App installation access token for the specified repository.
#
# The token is minted by:
# 1. Building an RS256-signed JWT from the App's client id and private key.
# 2. Resolving the App installation id for the owner (org, then repo, then user scope).
# 3. Exchanging the JWT for a repository-scoped installation access token.
#
# The resulting token is valid for ~1 hour and should be generated immediately before it is
# used (e.g. right before a git push or PR creation). Emitted on the pipeline via Write-Output.

[CmdletBinding()]
param (
[Parameter(Mandatory = $true)]
[string]
$AppClientId,
[Parameter(Mandatory = $true)]
[string]
$AppPrivateKeyContents,
[Parameter(Mandatory = $true)]
[ValidatePattern('^[a-zA-Z0-9_.-]+/[a-zA-Z0-9_.-]+$', ErrorMessage = "Repository must be in the format 'owner/repo' (e.g. 'octocat/hello-world')")]
[string]
$Repository
)

$ErrorActionPreference = "Stop"

function Generate-AppToken {
param (
[string]
$ClientId,
[string]
$PrivateKeyContents
)

$header = [Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes((ConvertTo-Json -InputObject @{
alg = "RS256"
typ = "JWT"
}))).TrimEnd('=').Replace('+', '-').Replace('/', '_');

$payload = [Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes((ConvertTo-Json -InputObject @{
iat = [System.DateTimeOffset]::UtcNow.AddSeconds(-10).ToUnixTimeSeconds()
exp = [System.DateTimeOffset]::UtcNow.AddMinutes(1).ToUnixTimeSeconds()
iss = $ClientId
}))).TrimEnd('=').Replace('+', '-').Replace('/', '_');

$rsa = [System.Security.Cryptography.RSA]::Create()
$rsa.ImportFromPem($PrivateKeyContents)

$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('/', '_')
$jwt = "$header.$payload.$signature"

return $jwt
}

function Generate-InstallationToken {
param (
[string]
$AppToken,
[string]
$InstallationId,
[string]
$Repository
)

$uri = "https://api.github.com/app/installations/$InstallationId/access_tokens"
$headers = @{
Authorization = "Bearer $AppToken"
Accept = "application/vnd.github+json"
"X-GitHub-Api-Version" = "2022-11-28"
}

$body = @{
repositories = @($Repository)
}

$response = Invoke-RestMethod -Uri $uri -Method Post -Headers $headers -Body (ConvertTo-Json -InputObject $body -Compress -Depth 10)

return $response.token
}

function Get-OrganizationInstallationId {
param (
[string]
$AppToken,
[string]
$Organization
)

$uri = "https://api.github.com/orgs/$Organization/installation"
$headers = @{
Authorization = "Bearer $AppToken"
Accept = "application/vnd.github+json"
"X-GitHub-Api-Version" = "2022-11-28"
}

try {
$response = Invoke-RestMethod -Uri $uri -Method Get -Headers $headers

return $response.id
}
catch [Microsoft.PowerShell.Commands.HttpResponseException] {
if ($_.Exception.Response.StatusCode -eq [System.Net.HttpStatusCode]::UnprocessableContent -or $_.Exception.Response.StatusCode -eq [System.Net.HttpStatusCode]::NotFound) {
return $null
}

throw
}
}

function Get-RepositoryInstallationId {
param (
[string]
$AppToken,
[string]
$Repository
)

$uri = "https://api.github.com/repos/$Repository/installation"
$headers = @{
Authorization = "Bearer $AppToken"
Accept = "application/vnd.github+json"
"X-GitHub-Api-Version" = "2022-11-28"
}

try {
$response = Invoke-RestMethod -Uri $uri -Method Get -Headers $headers

return $response.id
}
catch [Microsoft.PowerShell.Commands.HttpResponseException] {
if ($_.Exception.Response.StatusCode -eq [System.Net.HttpStatusCode]::UnprocessableContent -or $_.Exception.Response.StatusCode -eq [System.Net.HttpStatusCode]::NotFound) {
return $null
}

throw
}
}

function Get-UserInstallationId {
param (
[string]
$AppToken,
[string]
$Username
)

$uri = "https://api.github.com/users/$Username/installation"
$headers = @{
Authorization = "Bearer $AppToken"
Accept = "application/vnd.github+json"
"X-GitHub-Api-Version" = "2022-11-28"
}

try {
$response = Invoke-RestMethod -Uri $uri -Method Get -Headers $headers

return $response.id
}
catch [Microsoft.PowerShell.Commands.HttpResponseException] {
if ($_.Exception.Response.StatusCode -eq [System.Net.HttpStatusCode]::UnprocessableContent -or $_.Exception.Response.StatusCode -eq [System.Net.HttpStatusCode]::NotFound) {
return $null
}

throw
}
}

function Get-InstallationId {
param (
[string]
$AppToken,
[string]
$Owner,
[string]
$Repo
)

$orgInstallationId = Get-OrganizationInstallationId -AppToken $AppToken -Organization $Owner

if ($null -eq $orgInstallationId) {
$repoInstallationId = Get-RepositoryInstallationId -AppToken $AppToken -Repository "$Owner/$Repo"
}
else {
return $orgInstallationId
}

if ($null -eq $repoInstallationId) {
$userInstallationId = Get-UserInstallationId -AppToken $AppToken -Username $Owner
}
else {
return $repoInstallationId
}

if ($null -eq $userInstallationId) {
throw "Installation not found for repository '$Repo'"
}
else {
return $userInstallationId
}
}

$owner, $repo = $Repository -split '/'

$AppToken = Generate-AppToken -ClientId $AppClientId -PrivateKeyContents $AppPrivateKeyContents

$InstallationId = Get-InstallationId -AppToken $AppToken -Owner $owner -Repo $repo

$InstallationToken = Generate-InstallationToken -AppToken $AppToken -InstallationId $InstallationId -Repository $repo

Write-Output $InstallationToken
Loading