-
Notifications
You must be signed in to change notification settings - Fork 2.1k
Harden workflow credential selection #7249
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
moonbox3
merged 3 commits into
microsoft:main
from
moonbox3:moonbox3/github-app-auth-fallback
Jul 22, 2026
Merged
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,112 @@ | ||
| name: Get GitHub automation token | ||
| description: Creates a GitHub App installation token with a temporary PAT fallback | ||
|
|
||
| inputs: | ||
| mode: | ||
| description: Authentication mode (app, app-with-fallback, or pat) | ||
| required: false | ||
| default: app-with-fallback | ||
| azure-client-id: | ||
| description: Client ID of the Azure workload identity | ||
| required: false | ||
| azure-tenant-id: | ||
| description: Azure tenant ID | ||
| required: false | ||
| azure-subscription-id: | ||
| description: Azure subscription containing the Key Vault | ||
| required: false | ||
| key-vault-name: | ||
| description: Azure Key Vault name | ||
| required: false | ||
| key-name: | ||
| description: Key Vault key used to sign the GitHub App JWT | ||
| required: false | ||
| github-app-client-id: | ||
| description: GitHub App client ID | ||
| required: false | ||
| github-app-installation-id: | ||
| description: GitHub App installation ID | ||
| required: false | ||
| repository: | ||
| description: Repository to include in the installation token | ||
| required: false | ||
| fallback-token: | ||
| description: PAT used temporarily when app authentication is unavailable | ||
| required: false | ||
|
|
||
| outputs: | ||
| token: | ||
| description: GitHub App installation token or fallback PAT | ||
| value: ${{ steps.select-token.outputs.token }} | ||
| source: | ||
| description: Selected authentication source | ||
| value: ${{ steps.select-token.outputs.source }} | ||
|
|
||
| runs: | ||
| using: composite | ||
| steps: | ||
| - name: Validate authentication mode | ||
| shell: bash | ||
| env: | ||
| AUTH_MODE: ${{ inputs.mode || 'app-with-fallback' }} | ||
| run: | | ||
| if [[ "$AUTH_MODE" != "app" && "$AUTH_MODE" != "app-with-fallback" && "$AUTH_MODE" != "pat" ]]; then | ||
| echo "::error::Unsupported GitHub authentication mode." | ||
| exit 1 | ||
| fi | ||
|
|
||
| - name: Sign in to Azure | ||
| id: azure-login | ||
| if: ${{ (inputs.mode || 'app-with-fallback') != 'pat' }} | ||
| continue-on-error: true | ||
| uses: azure/login@a457da9ea143d694b1b9c7c869ebb04ebe844ef5 # v2 | ||
| with: | ||
| client-id: ${{ inputs.azure-client-id }} | ||
| tenant-id: ${{ inputs.azure-tenant-id }} | ||
| subscription-id: ${{ inputs.azure-subscription-id }} | ||
|
|
||
| - name: Create GitHub App installation token | ||
| id: app-token | ||
| if: ${{ (inputs.mode || 'app-with-fallback') != 'pat' && steps.azure-login.outcome == 'success' }} | ||
| continue-on-error: true | ||
| shell: bash | ||
| env: | ||
| AZURE_SUBSCRIPTION_ID: ${{ inputs.azure-subscription-id }} | ||
| KEY_VAULT_NAME: ${{ inputs.key-vault-name }} | ||
| KEY_NAME: ${{ inputs.key-name }} | ||
| GITHUB_APP_CLIENT_ID: ${{ inputs.github-app-client-id }} | ||
| GITHUB_APP_INSTALLATION_ID: ${{ inputs.github-app-installation-id }} | ||
| TARGET_REPOSITORY: ${{ inputs.repository }} | ||
| run: | | ||
| token="$(node "$GITHUB_ACTION_PATH/create-token.js")" | ||
| echo "::add-mask::$token" | ||
| echo "token=$token" >> "$GITHUB_OUTPUT" | ||
|
|
||
| - name: Select authentication token | ||
| id: select-token | ||
| shell: bash | ||
| env: | ||
| AUTH_MODE: ${{ inputs.mode || 'app-with-fallback' }} | ||
| APP_TOKEN: ${{ steps.app-token.outputs.token }} | ||
| FALLBACK_TOKEN: ${{ inputs.fallback-token }} | ||
| run: | | ||
| if [[ "$AUTH_MODE" != "pat" && -n "$APP_TOKEN" ]]; then | ||
| token="$APP_TOKEN" | ||
| source="app" | ||
| echo "::notice::GitHub authentication source: app" | ||
| elif [[ "$AUTH_MODE" == "app-with-fallback" && -n "$FALLBACK_TOKEN" ]]; then | ||
| token="$FALLBACK_TOKEN" | ||
| source="pat-fallback" | ||
| echo "::warning::GitHub authentication source: PAT fallback" | ||
| elif [[ "$AUTH_MODE" == "pat" && -n "$FALLBACK_TOKEN" ]]; then | ||
| token="$FALLBACK_TOKEN" | ||
| source="pat-forced" | ||
| echo "::warning::GitHub authentication source: PAT (forced rollout mode)" | ||
| else | ||
| echo "::error::GitHub App authentication is unavailable and no fallback PAT was provided." | ||
| exit 1 | ||
| fi | ||
|
|
||
| echo "::add-mask::$token" | ||
| echo "token=$token" >> "$GITHUB_OUTPUT" | ||
| echo "source=$source" >> "$GITHUB_OUTPUT" |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,133 @@ | ||
| // Copyright (c) Microsoft. All rights reserved. | ||
|
|
||
| const crypto = require('node:crypto'); | ||
| const { execFileSync } = require('node:child_process'); | ||
|
|
||
| function base64Url(value) { | ||
| return Buffer.from(value).toString('base64url'); | ||
| } | ||
|
|
||
| function base64ToBase64Url(value) { | ||
| return Buffer.from(value, 'base64').toString('base64url'); | ||
| } | ||
|
|
||
| function createJwtSigningInput(clientId, nowSeconds) { | ||
| const header = base64Url(JSON.stringify({ alg: 'RS256', typ: 'JWT' })); | ||
| const payload = base64Url(JSON.stringify({ | ||
| iat: nowSeconds - 60, | ||
| exp: nowSeconds + 540, | ||
| iss: clientId, | ||
| })); | ||
| return `${header}.${payload}`; | ||
| } | ||
|
|
||
| function signJwt(signingInput, config, execute = execFileSync) { | ||
| const digest = crypto.createHash('sha256').update(signingInput).digest('base64'); | ||
| const signature = execute( | ||
| 'az', | ||
| [ | ||
| 'keyvault', 'key', 'sign', | ||
| '--subscription', config.azureSubscriptionId, | ||
| '--vault-name', config.keyVaultName, | ||
| '--name', config.keyName, | ||
| '--algorithm', 'RS256', | ||
| '--digest', digest, | ||
| '--query', 'signature', | ||
| '--output', 'tsv', | ||
| '--only-show-errors', | ||
| ], | ||
| { encoding: 'utf8' }, | ||
| ).trim(); | ||
|
|
||
| if (!signature) { | ||
| throw new Error('Key Vault returned an empty signature.'); | ||
| } | ||
|
|
||
| return `${signingInput}.${base64ToBase64Url(signature)}`; | ||
| } | ||
|
|
||
| async function createInstallationToken(config, dependencies = {}) { | ||
| const execute = dependencies.execute ?? execFileSync; | ||
| const request = dependencies.fetch ?? fetch; | ||
| const nowSeconds = dependencies.nowSeconds ?? Math.floor(Date.now() / 1000); | ||
| const repositoryParts = config.targetRepository.split('/'); | ||
|
|
||
| if (repositoryParts.length !== 2 || repositoryParts.some((part) => part.length === 0)) { | ||
| throw new Error('TARGET_REPOSITORY must use the owner/repository format.'); | ||
| } | ||
|
|
||
| const [, repository] = repositoryParts; | ||
| const signingInput = createJwtSigningInput(config.githubAppClientId, nowSeconds); | ||
| const jwt = signJwt(signingInput, config, execute); | ||
|
|
||
| const response = await request( | ||
| `https://api.github.com/app/installations/${config.githubAppInstallationId}/access_tokens`, | ||
| { | ||
| method: 'POST', | ||
| headers: { | ||
| Accept: 'application/vnd.github+json', | ||
| Authorization: `Bearer ${jwt}`, | ||
| 'X-GitHub-Api-Version': '2022-11-28', | ||
| }, | ||
| body: JSON.stringify({ | ||
| repositories: [repository], | ||
| permissions: { | ||
| contents: 'read', | ||
| issues: 'write', | ||
| members: 'read', | ||
| pull_requests: 'write', | ||
| }, | ||
| }), | ||
| }, | ||
| ); | ||
|
|
||
| if (!response.ok) { | ||
| throw new Error(`GitHub installation token request failed with HTTP ${response.status}.`); | ||
| } | ||
|
|
||
| const result = await response.json(); | ||
| if (typeof result.token !== 'string' || result.token.length === 0) { | ||
| throw new Error('GitHub returned an empty installation token.'); | ||
| } | ||
|
|
||
| return result.token; | ||
| } | ||
|
|
||
| function readConfig(environment) { | ||
| const config = { | ||
| azureSubscriptionId: environment.AZURE_SUBSCRIPTION_ID, | ||
| keyVaultName: environment.KEY_VAULT_NAME, | ||
| keyName: environment.KEY_NAME, | ||
| githubAppClientId: environment.GITHUB_APP_CLIENT_ID, | ||
| githubAppInstallationId: environment.GITHUB_APP_INSTALLATION_ID, | ||
| targetRepository: environment.TARGET_REPOSITORY, | ||
| }; | ||
|
|
||
| if (Object.values(config).some((value) => !value)) { | ||
| throw new Error('Required GitHub App authentication configuration is missing.'); | ||
| } | ||
|
|
||
| return config; | ||
| } | ||
|
|
||
| async function main() { | ||
| try { | ||
| const token = await createInstallationToken(readConfig(process.env)); | ||
| process.stdout.write(token); | ||
| } catch { | ||
| console.error('GitHub App token generation failed.'); | ||
| process.exitCode = 1; | ||
| } | ||
| } | ||
|
|
||
| if (require.main === module) { | ||
| void main(); | ||
| } | ||
|
|
||
| module.exports = { | ||
| base64ToBase64Url, | ||
| createInstallationToken, | ||
| createJwtSigningInput, | ||
| readConfig, | ||
| signJwt, | ||
| }; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,125 @@ | ||
| // Copyright (c) Microsoft. All rights reserved. | ||
|
|
||
| const { describe, it } = require('node:test'); | ||
| const assert = require('node:assert/strict'); | ||
|
|
||
| const { | ||
| base64ToBase64Url, | ||
| createInstallationToken, | ||
| createJwtSigningInput, | ||
| readConfig, | ||
| } = require('../actions/github-app-token/create-token.js'); | ||
|
|
||
| const CONFIG = { | ||
| azureSubscriptionId: 'subscription-id', | ||
| keyVaultName: 'vault-name', | ||
| keyName: 'key-name', | ||
| githubAppClientId: 'client-id', | ||
| githubAppInstallationId: '12345', | ||
| targetRepository: 'microsoft/agent-framework', | ||
| }; | ||
|
|
||
| describe('GitHub App token creation', () => { | ||
| it('creates a short-lived GitHub App JWT', () => { | ||
| const signingInput = createJwtSigningInput('client-id', 1_000); | ||
| const [encodedHeader, encodedPayload] = signingInput.split('.'); | ||
| const header = JSON.parse(Buffer.from(encodedHeader, 'base64url').toString()); | ||
| const payload = JSON.parse(Buffer.from(encodedPayload, 'base64url').toString()); | ||
|
|
||
| assert.deepEqual(header, { alg: 'RS256', typ: 'JWT' }); | ||
| assert.deepEqual(payload, { iat: 940, exp: 1_540, iss: 'client-id' }); | ||
| }); | ||
|
|
||
| it('converts Key Vault signatures to unpadded base64url', () => { | ||
| assert.equal(base64ToBase64Url('+/8='), '-_8'); | ||
| }); | ||
|
|
||
| it('requests a repository-scoped installation token', async () => { | ||
| let request; | ||
| const token = await createInstallationToken(CONFIG, { | ||
| nowSeconds: 1_000, | ||
| execute: (command, args) => { | ||
| assert.equal(command, 'az'); | ||
| assert.ok(args.includes('RS256')); | ||
| return '+/8=\n'; | ||
| }, | ||
| fetch: async (url, options) => { | ||
| request = { url, options }; | ||
| return { | ||
| ok: true, | ||
| json: async () => ({ token: 'installation-token' }), | ||
| }; | ||
| }, | ||
| }); | ||
|
|
||
| assert.equal(token, 'installation-token'); | ||
| assert.equal(request.url, 'https://api.github.com/app/installations/12345/access_tokens'); | ||
| assert.match(request.options.headers.Authorization, /^Bearer [^.]+\.[^.]+\.-_8$/); | ||
| assert.deepEqual(JSON.parse(request.options.body), { | ||
| repositories: ['agent-framework'], | ||
| permissions: { | ||
| contents: 'read', | ||
| issues: 'write', | ||
| members: 'read', | ||
| pull_requests: 'write', | ||
| }, | ||
| }); | ||
| }); | ||
|
|
||
| it('rejects incomplete configuration', () => { | ||
| assert.throws( | ||
| () => readConfig({}), | ||
| /Required GitHub App authentication configuration is missing/, | ||
| ); | ||
| }); | ||
|
|
||
| it('rejects repository values with extra path segments before signing', async () => { | ||
| let signed = false; | ||
|
|
||
| await assert.rejects( | ||
| createInstallationToken( | ||
| { ...CONFIG, targetRepository: 'microsoft/agent-framework/extra' }, | ||
| { | ||
| execute: () => { | ||
| signed = true; | ||
| return '+/8=\n'; | ||
| }, | ||
| }, | ||
| ), | ||
| /TARGET_REPOSITORY must use the owner\/repository format/, | ||
| ); | ||
| assert.equal(signed, false); | ||
| }); | ||
|
|
||
| it('rejects an empty Key Vault signature', async () => { | ||
| await assert.rejects( | ||
| createInstallationToken(CONFIG, { | ||
| execute: () => '\n', | ||
| }), | ||
| /Key Vault returned an empty signature/, | ||
| ); | ||
| }); | ||
|
|
||
| it('rejects a failed GitHub token request', async () => { | ||
| await assert.rejects( | ||
| createInstallationToken(CONFIG, { | ||
| execute: () => '+/8=\n', | ||
| fetch: async () => ({ ok: false, status: 403 }), | ||
| }), | ||
| /GitHub installation token request failed with HTTP 403/, | ||
| ); | ||
| }); | ||
|
|
||
| it('rejects an empty GitHub installation token', async () => { | ||
| await assert.rejects( | ||
| createInstallationToken(CONFIG, { | ||
| execute: () => '+/8=\n', | ||
| fetch: async () => ({ | ||
| ok: true, | ||
| json: async () => ({ token: '' }), | ||
| }), | ||
| }), | ||
| /GitHub returned an empty installation token/, | ||
| ); | ||
| }); | ||
| }); | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.