Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
25 changes: 25 additions & 0 deletions eng/common/pipelines/templates/steps/login-to-github.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
# Will output a variable named GH_TOKEN_<Owner> for each owner in TokenOwners if there is only one owner it will just output GH_TOKEN

parameters:
- name: TokenOwners
type: object
default:
- Azure
- name: VariableNamePrefix
type: string
default: GH_TOKEN
- name: ScriptDirectory
default: eng/common/scripts

steps:
- task: AzureCLI@2
displayName: "Login to GitHub"
inputs:
azureSubscription: 'AzureSDKEngKeyVault Secrets'
scriptType: pscore
scriptLocation: scriptPath
scriptPath: ${{ parameters.ScriptDirectory }}/login-to-github.ps1
arguments: >
-InstallationTokenOwners '${{ join(''',''', parameters.TokenOwners) }}'
-VariableNamePrefix '${{ parameters.VariableNamePrefix }}'

177 changes: 177 additions & 0 deletions eng/common/scripts/login-to-github.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
<#
.SYNOPSIS
Mints a GitHub App installation access token using Azure Key Vault 'sign' (non-exportable key),
and logs in the GitHub CLI by setting GH_TOKEN.

.PARAMETER KeyVaultName
Name of the Azure Key Vault containing the non-exportable RSA key.

.PARAMETER KeyName
Name of the RSA key in Key Vault (imported as a *key*, not a secret).

.PARAMETER GitHubAppId
Numeric App ID (not client ID) of your GitHub App.

.PARAMETER InstallationTokenOwners
List of GitHub organizations or users for which to obtain installation tokens.

.PARAMETER VariableNamePrefix
Name of the ADO variable to set when -SetPipelineVariable is used (default: GH_TOKEN).

.OUTPUTS
Writes minimal info to stdout. Token is placed in $env:GH_TOKEN if there is only one owner otherwise $env:GH_TOKEN_<Owner> for each owner.
#>

[CmdletBinding()]
param(
[string] $KeyVaultName = "azuresdkengkeyvault",
[string] $KeyName = "azure-sdk-automation",
[string] $GitHubAppId = '1086291', # Azure SDK Automation App ID
[string[]] $InstallationTokenOwners = @("Azure"),
[string] $VariableNamePrefix = "GH_TOKEN"
)

$ErrorActionPreference = 'Stop'
Set-StrictMode -Version Latest

$GitHubApiBaseUrl = "https://api.github.com"
$GitHubApiVersion = "2022-11-28"

function Get-Headers {
param(
[Parameter(Mandatory)][string] $Jwt,
[Parameter(Mandatory)][string] $ApiVersion
)
return @{
'Authorization' = "Bearer $Jwt"
'Accept' = 'application/vnd.github+json'
'X-GitHub-Api-Version' = $ApiVersion
'User-Agent' = 'ado-pwsh-ghapp'
}
}

function New-GitHubAppJwt {
param(
[Parameter(Mandatory)] [string] $VaultName,
[Parameter(Mandatory)] [string] $KeyName,
[Parameter(Mandatory)] [string] $AppId
)

function Base64UrlEncode($json) {
$bytes = [System.Text.Encoding]::UTF8.GetBytes($json)
$base64 = [Convert]::ToBase64String($bytes)
return $base64.TrimEnd('=') -replace '\+', '-' -replace '/', '_'
}

# === STEP 1: Create JWT Header and Payload ===
$Header = @{
alg = "RS256"
typ = "JWT"
}
$Now = [int][double]::Parse((Get-Date -UFormat %s))
$Payload = @{
iat = $Now
exp = $Now + 600 # 10 minutes
iss = $AppId
}

$EncodedHeader = Base64UrlEncode (ConvertTo-Json $Header -Compress)
$EncodedPayload = Base64UrlEncode (ConvertTo-Json $Payload -Compress)
$UnsignedToken = "$EncodedHeader.$EncodedPayload"

# === STEP 2: Sign the token using Azure CLI ===
$UnsignedTokenBytes = [System.Security.Cryptography.SHA256]::Create().ComputeHash([Text.Encoding]::ASCII.GetBytes($UnsignedToken))
$Base64Value = [Convert]::ToBase64String($UnsignedTokenBytes)

$SignResultJson = az keyvault key sign `
--vault-name $VaultName `
--name $KeyName `
--algorithm RS256 `
--digest $Base64Value | ConvertFrom-Json

if ($LASTEXITCODE -ne 0) {
throw "Failed to sign JWT with Azure Key Vault. Error: $SignResult"
}

if (!$SignResultJson.signature) {
throw "Azure Key Vault response does not contain a signature. Response: $($SignResultJson | ConvertTo-Json -Compress)"
}

$Signature = $SignResultJson.signature
return "$UnsignedToken.$Signature"
}

function Get-GitHubInstallationId {
param(
[Parameter(Mandatory)][string] $Jwt,
[Parameter(Mandatory)][string] $ApiBase,
[Parameter(Mandatory)][string] $ApiVersion,
[Parameter(Mandatory)][string] $InstallationTokenOwner
)

$headers = Get-Headers -Jwt $Jwt -ApiVersion $ApiVersion

$uri = "$ApiBase/app/installations"
$resp = Invoke-RestMethod -Method Get -Headers $headers -Uri $uri -TimeoutSec 30 -MaximumRetryCount 3

$resp | Foreach-Object { Write-Host " $($_.id): $($_.account.login) [$($_.target_type)]" }

$resp = $resp | Where-Object { $_.account.login -ieq $InstallationTokenOwner }
if (!$resp.id) { throw "No installations found for this App." }
return $resp.id
}

function New-GitHubInstallationToken {
param(
[Parameter(Mandatory)] [string] $Jwt,
[Parameter(Mandatory)] [string] $InstallationId,
[Parameter(Mandatory)] [string] $ApiBase,
[Parameter(Mandatory)] [string] $ApiVersion
)
$headers = Get-Headers -Jwt $Jwt -ApiVersion $ApiVersion
$uri = "$ApiBase/app/installations/$InstallationId/access_tokens"
$resp = Invoke-RestMethod -Method Post -Headers $headers -Uri $uri -TimeoutSec 30 -MaximumRetryCount 3
if (!$resp.token) { throw "Failed to obtain installation access token for installation $InstallationId." }
return $resp.token
}

Write-Host "Generating GitHub App JWT by signing via Azure Key Vault (no key export)..."
$jwt = New-GitHubAppJwt -VaultName $KeyVaultName -KeyName $KeyName -AppId $GitHubAppId

foreach ($InstallationTokenOwner in $InstallationTokenOwners)
{
Write-Host "Fetching installation ID for $InstallationTokenOwner ..."
$installationId = Get-GitHubInstallationId -Jwt $jwt -ApiBase $GitHubApiBaseUrl -ApiVersion $GitHubApiVersion -InstallationTokenOwner $InstallationTokenOwner

Write-Host "Installation ID resolved: $installationId"

Write-Host "Exchanging JWT for installation access token..."
$installationToken = New-GitHubInstallationToken -Jwt $jwt -InstallationId $installationId -ApiBase $GitHubApiBaseUrl -ApiVersion $GitHubApiVersion

$variableName = $VariableNamePrefix
if ($InstallationTokenOwners.Count -gt 1)
{
$variableName = $VariableNamePrefix + "_" + $InstallationTokenOwner
}

Set-Item -Path Env:$variableName -Value $installationToken

# Export for gh CLI & git
Write-Host "$variableName has been set in the current process."

# Optionally set an Azure DevOps secret variable (so later tasks can reuse it)
if ($null -ne $env:SYSTEM_TEAMPROJECTID) {
Write-Host "##vso[task.setvariable variable=$variableName;issecret=true]$installationToken"
Write-Host "Azure DevOps variable '$variableName' has been set (secret)."
}

try {
Write-Host "`n--- gh auth status ---"
$gh_token_value_before = $env:GH_TOKEN
$env:GH_TOKEN = $installationToken
& gh auth status
}
finally{
$env:GH_TOKEN = $gh_token_value_before
}
}
Loading