diff --git a/eng/common/pipelines/templates/steps/login-to-github.yml b/eng/common/pipelines/templates/steps/login-to-github.yml new file mode 100644 index 000000000000..3df66925da26 --- /dev/null +++ b/eng/common/pipelines/templates/steps/login-to-github.yml @@ -0,0 +1,25 @@ +# Will output a variable named GH_TOKEN_ 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 }}' + \ No newline at end of file diff --git a/eng/common/scripts/login-to-github.ps1 b/eng/common/scripts/login-to-github.ps1 new file mode 100644 index 000000000000..96023d290c4b --- /dev/null +++ b/eng/common/scripts/login-to-github.ps1 @@ -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_ 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 + } +}