Skip to content

Commit 752d83b

Browse files
CopilottspascoalCopilot
authored
Add support for enterprise installation tokens (#10)
* Add support for GitHub App enterprise installations If accountType is enterprise (owner is mandatory ands should be filled with the enterprise slug) returns an installation id for the app installed at the enterprise level so calls can be made at enterprise level. - Bump axios - Bump extension and task version --------- Co-authored-by: copilot-swe-agent[bot] <[email protected]> Co-authored-by: Tiago Pascoal <[email protected]> Co-authored-by: Copilot <[email protected]>
1 parent d176749 commit 752d83b

File tree

14 files changed

+780
-77
lines changed

14 files changed

+780
-77
lines changed

.gitignore

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ lib
88
.taskkey
99

1010
coverage/
11+
smoke-tests/
1112

1213
# Test results
13-
test-results/
14+
test-results/

README.md

Lines changed: 65 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,18 +8,18 @@ Azure Pipelines extension to create a GitHub App installation tokens that can be
88
## Features
99

1010
- Generates GitHub App installation tokens for API authentication
11-
- Supports organization-wide or repository-specific tokens
11+
- Supports organization-wide, user-level, or enterprise-level tokens
1212
- Provides multiple authentication options (service connection or direct inputs)
1313
- Handles proxy configurations through environment variables
1414
- Secure handling of private keys and credentials
1515
- Cross-platform compatibility
1616

1717
## Prerequisites
1818

19-
1. A GitHub App created and installed in your organization
19+
1. A GitHub App created and installed in your organization, user account, or enterprise
2020
2. The GitHub App's private key (PEM format)
2121
3. The GitHub App client ID
22-
4. The GitHub App must be installed in the organization where you want to generate tokens
22+
4. The GitHub App must be installed in the organization, user account, or enterprise where you want to generate tokens
2323

2424
## Configuration
2525

@@ -64,9 +64,9 @@ steps:
6464
| Parameter | Required | Description |
6565
|-----------|----------|-------------|
6666
| githubAppConnection | No | The GitHub App connection to use (preferred method) |
67-
| owner | No | The GitHub organization name or user account where the app is installed. If not provided, it will be automatically fetched from the `Build.Repository.Name` variable. |
68-
| accountType | No | The type of account to use for the token. Options: `org` (organization) or `user` (user account). Default is `org`. |
69-
| repositories | No | Comma-separated list of repositories to scope the token to. If empty, token will be scoped to all repositories (in which the app has access to) |
67+
| owner | No | The GitHub organization name, user account, or enterprise slug where the app is installed. **Required for enterprise account type.** If not provided (for org/user), it will be automatically fetched from the `Build.Repository.Name` variable. |
68+
| accountType | No | The type of account to use for the token. Options: `org` (organization), `user` (user account), or `enterprise` (enterprise account). Default is `org`. |
69+
| repositories | No | Comma-separated list of repositories to scope the token to. If empty, token will be scoped to all repositories (in which the app has access to). **Not allowed for enterprise account type.** |
7070
| appClientId | No* | The GitHub App ID (required if not using service connection) |
7171
| certificate | No* | The PEM certificate content (required if not using service connection) |
7272
| certificateFile | No | Alternative to certificate - filename containing the PEM content |
@@ -189,6 +189,41 @@ steps:
189189
permissions: '{"contents":"read","pulls":"write","issues":"write"}' # Restricts token permissions
190190
```
191191

192+
### Enterprise Installation Token
193+
194+
For GitHub Apps installed at the enterprise level:
195+
196+
```yaml
197+
steps:
198+
- task: create-github-app-token@1
199+
name: enterpriseToken
200+
inputs:
201+
githubAppConnection: 'MyGitHubAppConnection'
202+
accountType: 'enterprise'
203+
owner: 'my-enterprise' # Enterprise slug/name (required for enterprise)
204+
- bash: |
205+
gh api \
206+
--method GET -H "Accept: application/vnd.github+json" -H "X-GitHub-Api-Version: 2022-11-28" \
207+
/enterprises/my-enterprise
208+
displayName: 'Access enterprise using GitHub CLI'
209+
env:
210+
GH_TOKEN: $(enterpriseToken.installationToken)
211+
```
212+
213+
### Enterprise with Direct Certificate Input
214+
215+
```yaml
216+
steps:
217+
- task: create-github-app-token@1
218+
inputs:
219+
accountType: 'enterprise'
220+
owner: 'my-enterprise' # Required for enterprise account type
221+
appClientId: 'lv2313qqwqeqweqw'
222+
certificate: '$(githubAppPem)'
223+
# Note: repositories input not allowed for enterprise account type
224+
# Note: forceRepoScope not allowed for enterprise account type
225+
```
226+
192227
## Proxy Support
193228

194229
The task automatically detects and uses proxy settings from the following environment variables:
@@ -210,6 +245,30 @@ Common issues and solutions:
210245
- If you are trying to request `admin:read` permission, the app needs to have `admin:read` or `admin:org` in the GitHub App configuration
211246
- The token can only have equal or lower permissions than what is configured in the GitHub App settings
212247

248+
### Enterprise-Specific Issues
249+
250+
5. **Enterprise installation not found**:
251+
- Verify the GitHub App is installed at the enterprise level (not just organization level)
252+
- Ensure the `owner` parameter contains the correct enterprise slug/name
253+
- Check that the GitHub App has permissions to access enterprise resources
254+
6. **Multiple enterprise installations found**: If you have access to multiple enterprises, specify the exact enterprise slug in the `owner` parameter
255+
7. **Enterprise account type restrictions**:
256+
- Cannot use `repositories` parameter with enterprise account type (tokens are enterprise-scoped)
257+
- Cannot use `forceRepoScope` in service connections with enterprise account type
258+
- The `owner` parameter is mandatory for enterprise account type
259+
8. **Rate limiting during installation lookup**: Enterprise installations use pagination which may hit rate limits with many installations. The task automatically handles rate limiting by waiting if the reset time is within 5 minutes.
260+
261+
### Account Type Differences
262+
263+
| Feature | Organization | User | Enterprise |
264+
|---------|-------------|------|------------|
265+
| Repository scoping | ✅ Supported | ✅ Supported | ❌ Not supported |
266+
| forceRepoScope | ✅ Supported | ✅ Supported | ❌ Not supported |
267+
| Owner parameter | Optional* | Optional* | **Required** |
268+
| Direct API lookup | ✅ Yes | ✅ Yes | ❌ Uses pagination workaround |
269+
270+
*Owner is optional for org/user if using GitHub repository provider (auto-extracted from Build.Repository.Name)
271+
213272
> [!TIP]
214273
> If you expand the task logs, you can see extra info like the token permissions and repo access. (If you run the pipeline in debug mode it will have extra info as well).
215274

create-github-app-token/package-lock.json

Lines changed: 9 additions & 8 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

create-github-app-token/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
"license": "ISC",
1515
"description": "",
1616
"dependencies": {
17-
"axios": "^1.6.2",
17+
"axios": "^1.11.0",
1818
"azure-pipelines-task-lib": "^5.0.0",
1919
"http-proxy-agent": "^5.0.0",
2020
"https-proxy-agent": "^5.0.0",

create-github-app-token/src/core/github-service.ts

Lines changed: 133 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -35,10 +35,10 @@ export class GitHubService {
3535
}
3636

3737
async generateJWT(appIdOrClientId: string, privateKey: string): Promise<string> {
38-
const now = Math.floor(Date.now() / 1000);
38+
const nowSeconds = Math.floor(Date.now() / 1000);
3939
const payload = {
40-
iat: now - 60, // issued at time, 60s in the past to allow for clock drift
41-
exp: now + constants.JWT_EXPIRATION,
40+
iat: nowSeconds - constants.JWT_CLOCK_DRIFT_SECONDS, // issued at time, 60s in the past to allow for clock drift
41+
exp: nowSeconds + constants.JWT_EXPIRATION - constants.JWT_CLOCK_DRIFT_SECONDS, // expiration time, 10 minutes from now
4242
iss: appIdOrClientId // GitHub App's client (preferable) or app identifier
4343
};
4444

@@ -53,16 +53,17 @@ export class GitHubService {
5353
* - https://docs.github.com/en/rest/apps/apps?apiVersion=2022-11-28#get-a-user-installation-for-the-authenticated-app
5454
* - https://docs.github.com/en/rest/apps/apps?apiVersion=2022-11-28#get-an-organization-installation-for-the-authenticated-app
5555
* - https://docs.github.com/en/rest/apps/apps?apiVersion=2022-11-28#get-a-repository-installation-for-the-authenticated-app
56+
* - https://docs.github.com/en/rest/apps/apps?apiVersion=2022-11-28#list-installations-for-the-authenticated-app (for enterprise installations)
5657
*
5758
* @param jwtToken - The JSON Web Token (JWT) used for authentication with the GitHub API.
58-
* @param owner - The owner of the repository or organization (username or organization name).
59-
* @param isOrg - A boolean indicating whether the owner is an organization (not relevant if repo is being passed).
59+
* @param owner - The owner of the repository, organization, or enterprise (username, organization name, or enterprise slug).
60+
* @param accountType - The type of account: 'org', 'user', or 'enterprise'.
6061
* @param repositories - An optional array of repository names to narrow down the installation ID retrieval.
6162
* If provided, the first repository in the array will be used to get the installation token.
6263
* @returns A promise that resolves to the installation ID of the GitHub App.
6364
* @throws An error if the repository name is invalid or if the API request fails.
6465
*/
65-
async getInstallationId(jwtToken: string, owner: string, isOrg: boolean, repositories: string[] = []): Promise<number> {
66+
async getInstallationId(jwtToken: string, appClientId: string, owner: string, accountType: string, repositories: string[] = []): Promise<number> {
6667
let url = undefined
6768
let groupName = '';
6869
let id = 0;
@@ -77,7 +78,10 @@ export class GitHubService {
7778
if (!validateRepositoryName(repo)) {
7879
throw new Error(`Invalid repository name format: ${repo}. It can only contain ASCII letters, digits, and the characters ., -, and _`);
7980
}
80-
} else if (isOrg) {
81+
} else if (accountType.toLowerCase() === constants.ACCOUNT_TYPE_ENTERPRISE) {
82+
// Enterprise installations require pagination through all installations
83+
return await this.getEnterpriseInstallationId(jwtToken, appClientId);
84+
} else if (accountType.toLowerCase() === constants.ACCOUNT_TYPE_ORG) {
8185
groupName = `##[group]Get Installation ID for organization ${owner}`;
8286
url = `${this.baseUrl}/orgs/${owner}/installation`;
8387
} else {
@@ -113,9 +117,17 @@ export class GitHubService {
113117
tl.debug(`Target type: ${response.data.target_type}`);
114118

115119
} catch (err: any) {
120+
121+
this.dumpHeaders(err.response?.headers);
122+
116123
let message = '';
117124
if (err.status === 404) {
118-
const targetType = isOrg ? 'Organization' : 'account';
125+
let targetType = 'account';
126+
if (accountType.toLowerCase() === constants.ACCOUNT_TYPE_ORG) {
127+
targetType = 'Organization';
128+
} else if (accountType.toLowerCase() === constants.ACCOUNT_TYPE_ENTERPRISE) {
129+
targetType = 'Enterprise';
130+
}
119131
message = `GitHub App not found for ${targetType} ${owner}. Please verify the installation${repositories.length ? ' and repository access' : ''}.`;
120132
} else {
121133
message = `Failed to get installation ID: ${err.message}`;
@@ -127,6 +139,114 @@ export class GitHubService {
127139
return id;
128140
}
129141

142+
/**
143+
* Gets the installation ID for an enterprise installation by listing all installations
144+
* and filtering for enterprise type. Handles pagination automatically.
145+
*
146+
* Note: This is a workaround since there is no direct API to get the installation ID
147+
* for enterprise installations (unlike organizations and repositories).
148+
*
149+
* API: https://docs.github.com/en/rest/apps/apps?apiVersion=2022-11-28#list-installations-for-the-authenticated-app
150+
*
151+
* @param jwtToken - The JWT token for authentication
152+
* @param appIdOrClientId - GitHub App ID or Client ID to match against
153+
* @returns Promise<number> - The installation ID
154+
* @throws Error if no matching app installation found for enterprises, or API errors
155+
*/
156+
async getEnterpriseInstallationId(jwtToken: string, appIdOrClientId: string): Promise<number> {
157+
const groupName = `##[group]Get Installation ID for enterprise app ${appIdOrClientId}`;
158+
console.log(`##[group]${groupName}`);
159+
console.log('Searching for enterprise installation across all app installations (workaround - no direct API available)');
160+
161+
let page = 1;
162+
const perPage = 100; // Maximum allowed by GitHub API
163+
164+
try {
165+
while (true) {
166+
const url = `${this.baseUrl}/app/installations?per_page=${perPage}&page=${page}`;
167+
tl.debug(`Installation list request URL: ${url} (page ${page})`);
168+
169+
const response = await this.client.get(url, {
170+
headers: {
171+
'Authorization': `Bearer ${jwtToken}`
172+
}
173+
});
174+
175+
this.dumpHeaders(response.headers);
176+
tl.debug(`Response payload (page ${page}): ${JSON.stringify(response.data)}`);
177+
178+
const installations = response.data;
179+
tl.debug(`Processing page ${page} of installations (${installations.length} installations)`);
180+
181+
// Find matching installation by app ID or app client ID
182+
const matchingInstallation = installations.find((installation: any) =>
183+
installation.target_type === 'Enterprise' &&
184+
(
185+
installation.app_id?.toString() === appIdOrClientId ||
186+
installation.client_id === appIdOrClientId
187+
)
188+
);
189+
190+
if (matchingInstallation) {
191+
console.log(`Found enterprise installation for app ID/client ID '${appIdOrClientId}' on page ${page}`);
192+
const installationId = matchingInstallation.id;
193+
194+
tl.debug(`Enterprise installation found: ${matchingInstallation.account.name} (install ID: ${installationId})`);
195+
tl.debug(`Installation app slug: ${matchingInstallation.app_slug || 'N/A'}`);
196+
tl.debug(`Installation app name: ${matchingInstallation.app_name || 'N/A'}`);
197+
198+
return installationId;
199+
}
200+
201+
// Check if we've reached the end of results using GitHub's pagination headers
202+
const linkHeader = response.headers['link'];
203+
if (!linkHeader || !linkHeader.includes('rel="next"')) {
204+
console.log(`No 'next' link header found. Reached end of installations (page ${page}).`);
205+
break;
206+
}
207+
208+
// Check rate limiting and wait if necessary
209+
const rateLimitRemaining = parseInt(response.headers['x-ratelimit-remaining'] || '0');
210+
const rateLimitReset = parseInt(response.headers['x-ratelimit-reset'] || '0');
211+
const currentTime = Math.floor(Date.now() / 1000);
212+
213+
if (rateLimitRemaining === 0 && (rateLimitReset - currentTime) <= 300) {
214+
const waitTime = (rateLimitReset - currentTime) + 10; // Add 10 seconds buffer
215+
console.log(`Rate limit approaching. Waiting ${waitTime} seconds before next request.`);
216+
await new Promise(resolve => setTimeout(resolve, waitTime * 1000));
217+
} else if (rateLimitRemaining === 0) {
218+
const resetTime = new Date(rateLimitReset * 1000).toISOString();
219+
throw new Error(`GitHub API rate limit exceeded. Reset time: ${resetTime}. Please try again later.`);
220+
}
221+
222+
page++;
223+
}
224+
225+
// If we reach here, the enterprise installation was not found
226+
throw new Error(`GitHub App installation not found for app ID/client ID '${appIdOrClientId}'. Please verify the app ID/client ID and enterprise installation.`);
227+
228+
} catch (err: any) {
229+
let message = '';
230+
if (err.response && err.response.status === 401) {
231+
message = `GitHub App JWT authentication failed. Please verify the app credentials.`;
232+
} else if (err.response && err.response.status === 403) {
233+
message = `GitHub App does not have permission to list installations. Please verify the app permissions.`;
234+
} else if (err.message.includes('rate limit') || err.message.includes('Rate limit')) {
235+
throw err; // Re-throw rate limit errors as-is
236+
} else {
237+
message = err.message || `Failed to get enterprise installation ID: ${err}`;
238+
}
239+
240+
if (message !== err.message) {
241+
throw new Error(message);
242+
} else {
243+
throw err;
244+
}
245+
} finally {
246+
console.log('##[endgroup]')
247+
}
248+
}
249+
130250
/**
131251
* Generates an installation token for a GitHub App installation.
132252
*
@@ -240,11 +360,12 @@ export class GitHubService {
240360
* @param headers - An object containing the headers to be logged, where each key is the header name
241361
* and the corresponding value is the header value.
242362
*/
243-
private dumpHeaders(headers: any) {
244-
Object.keys(headers).forEach((key) => {
245-
const value = headers[key];
363+
private dumpHeaders(headers: Record<string, any> | undefined | null): void {
364+
if (!headers || tl.getVariable('System.Debug')?.toLowerCase() !== 'true') return;
365+
366+
for (const [key, value] of Object.entries(headers)) {
246367
tl.debug(`Header: ${key} = ${value}`);
247-
});
368+
}
248369
}
249370

250371
private formatPermissions(permissions: any): string {

0 commit comments

Comments
 (0)