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
15 changes: 11 additions & 4 deletions Documentation/OneLocBuild.md
Original file line number Diff line number Diff line change
Expand Up @@ -203,13 +203,20 @@ The parameters that can be passed to the template are as follows:
| `LclSource` | `LclFilesInRepo` | This passes the `LclSource` input to the OneLocBuild task as described in [its documentation](https://ceapex.visualstudio.com/CEINTL/_wiki/wikis/CEINTL.wiki/107/Localization-with-OneLocBuild-Task?anchor=languageset%2C-languages-(required)). For most repos, this should be set to `LclFilesfromPackage`. |
| `LclPackageId` | `''` | When `LclSource` is set to `LclFilesfromPackage`, this passes in the package ID as described in the [OneLocBuild task documentation](https://ceapex.visualstudio.com/CEINTL/_wiki/wikis/CEINTL.wiki/107/Localization-with-OneLocBuild-Task?anchor=scenario-2%3A-lcl-files-from-a-package). |
| `CeapexServiceConnection` | `'dnceng-onelocbuild-ceapex'` | The project-scoped WIF service connection used to acquire a short-lived token for the Ceapex feeds. OneLocBuild supports only `dnceng/internal` and `DevDiv/DevDiv`; pipelines must be authorized to use the connection. |
| `GitHubAppServiceConnection` | `'dnceng-oneloc-githubapp'` | The dnceng/internal WIF service connection used to sign the App JWT. When the value remains the default, Arcade automatically uses `devdiv-oneloc-githubapp` in `DevDiv/DevDiv`. |
| `GitHubAppClientId` | `'Iv23lijBU8x3gc9lDOc9'` | The GitHub App's Client ID. |
| `GitHubAppKeyVaultName` | `'EngKeyVault'` | Key Vault holding the App's RSA signing key. |
| `GitHubAppKeyName` | `'oneloc-localization-app-key'` | Name of the App's RSA signing key in the Key Vault. |
| `GitHubAppServiceConnection` | `'dnceng-oneloc-githubapp'` | Project-scoped WIF service connection used to read the App credentials from Key Vault. DevDiv automatically uses `devdiv-oneloc-githubapp` when this default is unchanged. |
| `GitHubAppKeyVaultName` | `'EngKeyVault'` | Key Vault containing the Secret Manager-managed GitHub App credentials. |
| `GitHubAppIdSecretName` | `'oneloc-localization-app-app-id'` | Secret Manager projection containing the GitHub App ID. |
| `GitHubAppPrivateKeySecretName` | `'oneloc-localization-app-app-private-key'` | Secret Manager projection containing the PEM private key. |
| `condition` | `''` | Allows for conditionalizing the template's steps on build-time variables. |
| `JobNameSuffix` | `''` | Allows for custom job name suffix. This is helpful for disambiguation in case of need for more then one OneLocBuild job run - e.g. as a way to set multiple package IDs. |

GitHub OneLoc pipelines must be authorized to use their project's GitHub App WIF service
connection. The service connection has read access only to the two required Key Vault secrets.

The previous Key Vault RSA signing parameters have been removed. See
[Authenticating OneLocBuild's GitHub check-in with the GitHub App](OneLocBuildGitHubApp.md#migrating-from-key-vault-rsa-signing)
for the required parameter migration.

It is recommended that you set `LclSource` and `LclPackageId` as shown in the example above.


Expand Down
61 changes: 41 additions & 20 deletions Documentation/OneLocBuildGitHubApp.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,8 @@ whenever `RepoType` is `gitHub`. OneLocBuild supports the **`dnceng/internal`**
**`DevDiv/DevDiv`** Azure DevOps projects.

When those hold, the job runs [`get-github-app-token.yml`](/eng/common/core-templates/steps/get-github-app-token.yml),
which signs a JWT with the App's RSA key in Key Vault, exchanges it for an installation token, and
passes that token to the OneLocBuild task via `gitHubPatVariable`.
which signs a JWT with the Secret Manager-managed App private key, exchanges it for an installation
token, and passes that token to the OneLocBuild task via `gitHubPatVariable`.

If App token minting or authentication fails, the job fails; there is no stored-PAT fallback.

Expand All @@ -35,16 +35,17 @@ If App token minting or authentication fails, the job fails; there is no stored-
1. **The App must be installed on the GitHub org/account that owns your target repo, and your
specific repository must be selected in that installation.** The App can only open a PR against a
repository it is installed on. This is what actually grants the App permission to your repo.
2. **Your pipeline must run in `dnceng/internal` or `DevDiv/DevDiv` and be authorized to use that
project's App service connection.**
2. **Your pipeline must run in `dnceng/internal` or `DevDiv/DevDiv` and be authorized to use its
project's GitHub App WIF service connection.** The shared OneLoc job uses that identity to read
only the two required Secret Manager projections from Key Vault.

The .NET Engineering Services team manages the App signing key and the project-scoped service
connections. Contact the First Responders to authorize an intended pipeline.
The .NET Engineering Services team manages the App credentials, Key Vault permissions, and
service connections.

### Step 1 — Request that your repository be added to the App installation

The App installation and the backing `dnceng/internal` service connection / Key Vault key are
managed by the .NET Engineering Services (dnceng) team. To have your repo added:
The App installation and backing Secret Manager values are managed by the .NET Engineering
Services (dnceng) team. To have your repo added:

1. Identify the **GitHub org** and **repository** your OneLoc check-in PR targets. For most repos
this is the value of the `GitHubOrg` parameter (default `dotnet`) and your repo name. If you use
Expand Down Expand Up @@ -74,30 +75,52 @@ OneLocBuild template call. For example:
LclPackageId: 'LCL-JUNO-PROD-YOURREPO'
```

Arcade automatically selects the project-scoped service connection:
The project-specific WIF service connection reads the App ID and private key directly from
EngKeyVault:

| Azure DevOps project | Service connection |
|---|---|
| `dnceng/internal` | `dnceng-oneloc-githubapp` |
| `DevDiv/DevDiv` | `devdiv-oneloc-githubapp` |

The App client ID, Key Vault, and key name are also centralized in the Arcade template. A pipeline
still needs one-time authorization to use its project's connection.
Each identity has `Key Vault Secrets User` access scoped to only
`oneloc-localization-app-app-id` and `oneloc-localization-app-app-private-key`. Each pipeline must
be authorized to use its project's service connection.

### GitHub App parameters

| **Parameter** | **Default** | **Notes** |
|:-:|:-:|-|
| `GitHubAppServiceConnection` | `'dnceng-oneloc-githubapp'` | The Azure DevOps **WIF service connection** used by `dnceng/internal`. When the value remains the default, Arcade selects `devdiv-oneloc-githubapp` automatically in `DevDiv/DevDiv`. |
| `GitHubAppClientId` | `'Iv23lijBU8x3gc9lDOc9'` | The GitHub App's **Client ID** (used as the JWT `iss` claim). |
| `GitHubAppKeyVaultName` | `'EngKeyVault'` | The Key Vault holding the App's RSA signing key. |
| `GitHubAppKeyName` | `'oneloc-localization-app-key'` | The name of the RSA key inside that Key Vault (the App's private key). |
| `GitHubAppServiceConnection` | `dnceng-oneloc-githubapp` | WIF service connection used to read the App credentials. DevDiv automatically selects `devdiv-oneloc-githubapp` when this default is unchanged. |
| `GitHubAppKeyVaultName` | `EngKeyVault` | Key Vault containing the Secret Manager projections. |
| `GitHubAppIdSecretName` | `oneloc-localization-app-app-id` | Secret containing the GitHub App ID. |
| `GitHubAppPrivateKeySecretName` | `oneloc-localization-app-app-private-key` | Secret containing the PEM private key. |

The token is minted for the installation on the `GitHubOrg` account (default `dotnet`), so make sure
`GitHubOrg` (and `MirrorRepo`, if mirroring) point at the org/repo where the App is installed.

### Migrating from Key Vault RSA signing

The Key Vault RSA signing path and pipeline-variable credential path have both been removed.

OneLoc job callers using `GitHubAppId` and `GitHubAppPrivateKey` must remove those parameters.
Callers from the older RSA interface must remove `GitHubAppClientId` and `GitHubAppKeyName`;
`GitHubAppServiceConnection` and `GitHubAppKeyVaultName` retain their meanings. Standard callers
need no replacement parameters because the four service-connection and secret-name defaults apply
automatically.

Direct callers of `get-github-app-token.yml` using `appId` and `appPrivateKey` must replace those
parameters with `azureSubscription`, `keyVaultName`, `appIdSecretName`, and
`appPrivateKeySecretName`. Direct callers from the older RSA interface keep `azureSubscription`
and `keyVaultName`, remove `keyName` and `appClientId`, and add the two secret-name parameters.
There is no fallback to the legacy RSA key.

## Verifying it works

Changes to the GitHub App credential retrieval path must pass a protected internal canary before
merge. Public pull-request builds intentionally cannot access the WIF service connection or App
private key, so syntax and unit checks alone do not validate this boundary.

1. Run your pipeline from a branch where the OneLocBuild job runs.
2. In the build, confirm the **`Get GitHub App installation token`** step runs and succeeds before
the `OneLocBuild` task.
Expand All @@ -107,11 +130,9 @@ The token is minted for the installation on the `GitHubOrg` account (default `do
## Troubleshooting

- **The App-token step is skipped.** The App path activates when `RepoType` is `gitHub`.
- **The pipeline pauses for service-connection authorization.** Authorize the pipeline to use
`dnceng-oneloc-githubapp` in `dnceng/internal` or `devdiv-oneloc-githubapp` in `DevDiv/DevDiv`.
- **Token minting fails with a Key Vault authorization error.** The service connection identity
needs the `Key Vault Crypto User` role (or at least the `Sign` action) on the App's key. Contact
First Responders.
- **The App ID or private key cannot be read.** Confirm the pipeline is authorized to use its
project's GitHub App service connection and that its identity has `Key Vault Secrets User`
access to both configured secrets.
- **`404`/`Not Found` when requesting the installation token.** The App is not installed on the
`GitHubOrg` account, or your repository was not selected in the installation. Complete Step 1.
- **PR fails to open on your repo.** Ensure the App has `Contents` and `Pull requests` (read &
Expand Down
123 changes: 83 additions & 40 deletions eng/common/Get-GitHubAppToken.ps1
Original file line number Diff line number Diff line change
@@ -1,13 +1,11 @@
# Mints a short-lived GitHub App installation access token by signing a JWT
# with a private key stored in Azure Key Vault (RSA, RS256). The signed JWT is
# exchanged with the GitHub API for a token scoped to a single installation.
# with an RSA private key (RS256). The signed JWT is exchanged with the GitHub
# API for a token scoped to a single installation.
#
# Requirements:
# - A GitHub App whose private key has been uploaded into Key Vault as an RSA
# key (the PEM converted to a Key Vault *key*, NOT stored as a secret).
# - The caller (the federated Azure service connection used to run this script)
# must have the `Key Vault Crypto User` role (or at minimum the `Sign`
# action) on that key.
# - A GitHub App ID and PEM private key stored as Azure Key Vault secrets.
# - The federated Azure service connection running this script must have
# `Get` access to those two secrets.
# - The App must be installed on the target organization/account
# (`InstallationOwner`) with the permissions/repositories it needs.
#
Expand All @@ -16,17 +14,17 @@

[CmdletBinding()]
param(
# Name of the Key Vault that holds the GitHub App's RSA signing key.
# Name of the Key Vault holding the GitHub App credentials.
[Parameter(Mandatory = $true)]
[string] $KeyVaultName,

# Name of the RSA key inside the Key Vault (the App's private key).
# Secret Manager projection containing the GitHub App ID.
[Parameter(Mandatory = $true)]
[string] $KeyName,
[string] $AppIdSecretName,

# The GitHub App's Client ID (the value to put in the `iss` JWT claim).
# Secret Manager projection containing the PEM private key.
[Parameter(Mandatory = $true)]
[string] $AppClientId,
[string] $AppPrivateKeySecretName,

# Login of the organization or user account whose installation we should
# mint the token for (e.g. `dotnet`, `microsoft`).
Expand All @@ -39,16 +37,69 @@ param(
[Parameter(Mandatory = $false)]
[string] $OutputVariableName
)

$ErrorActionPreference = 'Stop'
$PSNativeCommandUseErrorActionPreference = $true

. $PSScriptRoot\pipeline-logging-functions.ps1

if ($KeyVaultName -notmatch '^[A-Za-z][A-Za-z0-9-]{1,22}[A-Za-z0-9]$' -or $KeyVaultName.Contains('--')) {
Write-PipelineTelemetryError -Category 'Build' -Message "KeyVaultName '$KeyVaultName' is not a valid Azure Key Vault name."
exit 1
}

function ConvertTo-Base64Url([byte[]] $bytes) {
return [Convert]::ToBase64String($bytes).TrimEnd('=').Replace('+', '-').Replace('/', '_')
}

$previousNativeCommandErrorPreference = $PSNativeCommandUseErrorActionPreference
try {
# Azure CLI can emit non-fatal Python warnings to stderr.
$PSNativeCommandUseErrorActionPreference = $false
$keyVaultAccessToken = az account get-access-token `
--resource https://vault.azure.net `
--query accessToken `
--output tsv `
--only-show-errors
$tokenExitCode = $LASTEXITCODE
}
catch {
Write-PipelineTelemetryError -Category 'Build' -Message "Failed to acquire an Azure Key Vault access token: $_"
exit 1
}
finally {
$PSNativeCommandUseErrorActionPreference = $previousNativeCommandErrorPreference
}
if ($tokenExitCode -ne 0 -or [string]::IsNullOrWhiteSpace($keyVaultAccessToken)) {
Write-PipelineTelemetryError -Category 'Build' -Message "'az account get-access-token' exited with code $tokenExitCode while acquiring an Azure Key Vault access token."
exit 1
}

function Get-KeyVaultSecret([string] $SecretName) {
# Use the data-plane REST API because `az keyvault secret show` can fail
# with Errno 22 on hosted Windows agents when reading these projections.
$escapedSecretName = [Uri]::EscapeDataString($SecretName)
$secretUri = "https://$KeyVaultName.vault.azure.net/secrets/$escapedSecretName`?api-version=7.4"
try {
$response = Invoke-RestMethod `
-Uri $secretUri `
-Headers @{ Authorization = "Bearer $keyVaultAccessToken" } `
-Method Get
}
catch {
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."
exit 1
}
if ([string]::IsNullOrWhiteSpace($response.value)) {
Write-PipelineTelemetryError -Category 'Build' -Message "Secret '$SecretName' in vault '$KeyVaultName' is empty."
exit 1
}
return [string] $response.value
}

Write-Host "Reading GitHub App credentials from vault '$KeyVaultName'..."
$appId = Get-KeyVaultSecret $AppIdSecretName
$privateKey = Get-KeyVaultSecret $AppPrivateKeySecretName

# Build JWT header and payload. Use [ordered] hashtables so JSON
# serialization is deterministic.
$jwtHeader = [ordered]@{
Expand All @@ -59,46 +110,38 @@ $now = [System.DateTimeOffset]::UtcNow
$jwtPayload = [ordered]@{
iat = $now.AddMinutes(-1).ToUnixTimeSeconds()
exp = $now.AddMinutes(5).ToUnixTimeSeconds()
iss = $AppClientId
iss = $appId
}

$headerEncoded = ConvertTo-Base64Url ([System.Text.Encoding]::UTF8.GetBytes(($jwtHeader | ConvertTo-Json -Compress)))
$payloadEncoded = ConvertTo-Base64Url ([System.Text.Encoding]::UTF8.GetBytes(($jwtPayload | ConvertTo-Json -Compress)))
$signingInput = "$headerEncoded.$payloadEncoded"

# Key Vault `sign` expects the *digest* (base64), not the raw bytes.
$sha256 = [System.Security.Cryptography.SHA256]::Create()
$digestBytes = $sha256.ComputeHash([System.Text.Encoding]::UTF8.GetBytes($signingInput))
$digestBase64 = [Convert]::ToBase64String($digestBytes)
$sha256 = [System.Security.Cryptography.SHA256]::Create()
try {
$digestBytes = $sha256.ComputeHash([System.Text.Encoding]::UTF8.GetBytes($signingInput))
}
finally {
$sha256.Dispose()
}

Write-Host "Signing JWT with key '$KeyName' in vault '$KeyVaultName'..."
$previousNativeCommandErrorPreference = $PSNativeCommandUseErrorActionPreference
Write-Host 'Signing JWT with the GitHub App private key...'
$rsa = [System.Security.Cryptography.RSA]::Create()
try {
# Azure CLI can emit non-fatal Python warnings to stderr even when signing succeeds.
# Use the exit code to determine success for this invocation.
$PSNativeCommandUseErrorActionPreference = $false
$signatureBase64 = az keyvault key sign `
--vault-name $KeyVaultName `
--name $KeyName `
--algorithm RS256 `
--digest $digestBase64 `
--query signature `
--output tsv `
--only-show-errors
$signExitCode = $LASTEXITCODE
$rsa.ImportFromPem($privateKey)
$signatureBytes = $rsa.SignHash(
$digestBytes,
[System.Security.Cryptography.HashAlgorithmName]::SHA256,
[System.Security.Cryptography.RSASignaturePadding]::Pkcs1)
$signatureUrl = ConvertTo-Base64Url $signatureBytes
}
catch {
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."
Write-PipelineTelemetryError -Category 'Build' -Message "Failed to sign the GitHub App JWT with the supplied private key: $_"
exit 1
}
finally {
$PSNativeCommandUseErrorActionPreference = $previousNativeCommandErrorPreference
}
if ($signExitCode -ne 0 -or [string]::IsNullOrWhiteSpace($signatureBase64)) {
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."
exit 1
$rsa.Dispose()
}
$signatureUrl = $signatureBase64.Trim().TrimEnd('=').Replace('+', '-').Replace('/', '_')
$jwt = "$signingInput.$signatureUrl"

$headers = @{
Expand Down Expand Up @@ -126,7 +169,7 @@ try {
} while ($pageInstallationCount -eq 100)
}
catch {
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."
Write-PipelineTelemetryError -Category 'Build' -Message "Failed to list GitHub App installations: $_. The signed JWT may be invalid or the App ID may be incorrect."
exit 1
}
$matchingInstallations = @($installations | Where-Object { $_.account.login -ieq $InstallationOwner })
Expand Down
8 changes: 4 additions & 4 deletions eng/common/core-templates/job/onelocbuild.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,9 @@ parameters:

# GitHub App authentication for the OneLoc check-in PR.
GitHubAppServiceConnection: 'dnceng-oneloc-githubapp'
GitHubAppClientId: 'Iv23lijBU8x3gc9lDOc9'
GitHubAppKeyVaultName: 'EngKeyVault'
GitHubAppKeyName: 'oneloc-localization-app-key'
GitHubAppIdSecretName: 'oneloc-localization-app-app-id'
GitHubAppPrivateKeySecretName: 'oneloc-localization-app-app-private-key'

SourcesDirectory: $(System.DefaultWorkingDirectory)
CreatePr: true
Expand Down Expand Up @@ -101,8 +101,8 @@ jobs:
${{ else }}:
azureSubscription: ${{ parameters.GitHubAppServiceConnection }}
keyVaultName: ${{ parameters.GitHubAppKeyVaultName }}
keyName: ${{ parameters.GitHubAppKeyName }}
appClientId: ${{ parameters.GitHubAppClientId }}
appIdSecretName: ${{ parameters.GitHubAppIdSecretName }}
appPrivateKeySecretName: ${{ parameters.GitHubAppPrivateKeySecretName }}
installationOwner: ${{ parameters.GitHubOrg }}
outputVariableName: 'GitHubAppInstallationToken'
condition: ${{ parameters.condition }}
Expand Down
Loading
Loading