| FazBrowse GitHub Viewer | Trending | | Home |
| Tools: [Download Repo ZIP] [Original HTTPS Page] |
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
| 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" |
| 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, | ||
| }; |
| 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/, | ||
| ); | ||
| }); | ||
| }); | ||
| Back | FazBrowse Home | New Git URL |
Uh oh!
There was an error while loading. Please reload this page.