From 95717386c657bc772ba69db6fafa0e08baf5372e Mon Sep 17 00:00:00 2001 From: Thibault Derousseaux <6574550+tibdex@users.noreply.github.com> Date: Sat, 9 Sep 2023 15:08:19 -0400 Subject: [PATCH] Add support for organization and user installation retrieval and repository scoping (#84) --- .github/workflows/test.yml | 9 ++-- README.md | 21 ++++---- action.yml | 40 +++++++++++--- package-lock.json | 16 +----- package.json | 12 +++-- src/create-installation-access-token.ts | 59 ++++++++++++++++++++ src/fetch-installation-token.ts | 61 --------------------- src/index.ts | 49 ----------------- src/installation-retrieval.ts | 72 +++++++++++++++++++++++++ src/main.ts | 15 ++++++ src/parse-options.ts | 57 ++++++++++++++++++++ src/post.ts | 15 ++++++ src/revoke-installation-access-token.ts | 15 ++++++ src/run.ts | 12 +++++ src/state.ts | 1 + 15 files changed, 302 insertions(+), 152 deletions(-) create mode 100644 src/create-installation-access-token.ts delete mode 100644 src/fetch-installation-token.ts delete mode 100644 src/index.ts create mode 100644 src/installation-retrieval.ts create mode 100644 src/main.ts create mode 100644 src/parse-options.ts create mode 100644 src/post.ts create mode 100644 src/revoke-installation-access-token.ts create mode 100644 src/run.ts create mode 100644 src/state.ts diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index cc8407ac..f6834ed0 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -15,15 +15,16 @@ jobs: node-version: 20 cache: npm - run: npm ci + - run: npm run typecheck - run: npm run build - - run: npm run prettier -- --check # Optional integration test of the action using a dedicated GitHub App. - - id: generate_token + - id: create_token if: ${{ vars.TEST_GITHUB_APP_ID != '' }} uses: ./ with: # The only required permission is `Repository permissions > Metadata: Read-only`. app_id: ${{ vars.TEST_GITHUB_APP_ID }} private_key: ${{ secrets.TEST_GITHUB_APP_PRIVATE_KEY }} - - run: node --eval "assert('${{ steps.generate_token.outputs.token }}'.length > 0);" - if: ${{ steps.generate_token.outcome != 'skipped' }} + - if: ${{ steps.create_token.outcome != 'skipped' }} + run: node --eval "assert('${{ steps.create_token.outputs.token }}'.length > 0);" + - run: npm run prettier -- --check diff --git a/README.md b/README.md index 1647314f..1b5a20ba 100644 --- a/README.md +++ b/README.md @@ -18,9 +18,8 @@ jobs: job: runs-on: ubuntu-latest steps: - - name: Generate token - id: generate_token - uses: tibdex/github-app-token@v1 + - id: create_token + uses: tibdex/github-app-token@v2 with: app_id: ${{ secrets.APP_ID }} @@ -28,23 +27,23 @@ jobs: # github_api_url: https://api.example.com # Optional. - # installation_id: 1337 + # installation_retrieval_mode: id + + # Optional. + # installation_retrieval_payload: 1337 # Optional. # Using a YAML multiline string to avoid escaping the JSON quotes. # permissions: >- - # {"members": "read"} + # {"pull_requests": "read"} private_key: ${{ secrets.PRIVATE_KEY }} # Optional. - # repository: owner/repo + # repositories: >- + # ["actions/toolkit", "github/docs"] - - name: Use token - env: - TOKEN: ${{ steps.generate_token.outputs.token }} - run: | - echo "The generated token is masked: ${TOKEN}" + - run: "echo 'The created token is masked: ${{ steps.create_token.outputs.token }}'" ``` [Another use case for this action can (or could) be found in GitHub's own docs](https://web.archive.org/web/20230115194214/https://docs.github.com/en/issues/planning-and-tracking-with-projects/automating-your-project/automating-projects-using-actions#example-workflow-authenticating-with-a-github-app). diff --git a/action.yml b/action.yml index 1cf9da44..ca7ec4c2 100644 --- a/action.yml +++ b/action.yml @@ -5,25 +5,49 @@ inputs: app_id: description: ID of the GitHub App. required: true - installation_id: - description: The ID of the installation for which the token will be requested (defaults to the ID of the repository's installation). github_api_url: description: The API URL of the GitHub server. default: ${{ github.api_url }} + installation_retrieval_mode: + description: >- + The mode used to retrieve the installation for which the token will be requested. + + One of: + - id: use the installation with the specified ID. + - organization: use an organization installation (https://docs.github.com/en/rest/apps/apps?apiVersion=2022-11-28#get-an-organization-installation-for-the-authenticated-app). + - repository: use a repository installation (https://docs.github.com/en/rest/apps/apps?apiVersion=2022-11-28#get-a-repository-installation-for-the-authenticated-app). + - user: use a user installation (https://docs.github.com/en/rest/apps/apps?apiVersion=2022-11-28#get-a-user-installation-for-the-authenticated-app). + default: repository + installation_retrieval_payload: + description: >- + The payload used to retrieve the installation. + + Examples for each retrieval mode: + - id: 1337 + - organization: github + - repository: tibdex/github-app-token + - user: tibdex + default: ${{ github.repository }} permissions: - description: The JSON-stringified permissions granted to the token (defaults to all the GitHub app permissions, see https://docs.github.com/en/rest/apps/apps#create-an-installation-access-token-for-an-app). + description: >- + The JSON-stringified permissions granted to the token. + Defaults to all permissions granted to the GitHub app. + See https://docs.github.com/en/rest/apps/apps?apiVersion=2022-11-28#create-an-installation-access-token-for-an-app's `permissions`. private_key: description: Private key of the GitHub App (can be Base64 encoded). required: true - repository: - description: The full name of the repository for which the token will be requested. - default: ${{ github.repository }} + repositories: + description: >- + The JSON-stringified array of the full names of the repositories the token should have access to. + Defaults to all repositories that the installation can access. + See https://docs.github.com/en/rest/apps/apps?apiVersion=2022-11-28#create-an-installation-access-token-for-an-app's `repositories`. outputs: token: - description: An installation token for the GitHub App on the requested repository. + description: An installation access token for the GitHub App. runs: using: node20 - main: dist/index.js + main: dist/main/index.js + post: dist/post/index.js branding: icon: unlock color: gray-dark diff --git a/package-lock.json b/package-lock.json index 32eb0454..f1796ce1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,19 +1,18 @@ { "name": "github-app-token", - "version": "1.9.0", + "version": "2.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "github-app-token", - "version": "1.9.0", + "version": "2.0.0", "license": "MIT", "dependencies": { "@actions/core": "^1.10.0", "@actions/github": "^5.1.1", "@octokit/auth-app": "^6.0.0", "@octokit/request": "^8.1.1", - "ensure-error": "^4.0.0", "is-base64": "^1.1.0" }, "devDependencies": { @@ -942,17 +941,6 @@ "safe-buffer": "^5.0.1" } }, - "node_modules/ensure-error": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/ensure-error/-/ensure-error-4.0.0.tgz", - "integrity": "sha512-7Xenn3+R6tp2UqAbH9Jqs6QCSABQok+1VAhaPaF0jjm3iuhVHCblfBh18nYtpm3K9/V4Jpxz1JIqFZyrjstBtw==", - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/escape-string-regexp": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", diff --git a/package.json b/package.json index 7a4fe479..00e65a22 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "github-app-token", - "version": "1.9.0", + "version": "2.0.0", "license": "MIT", "type": "module", "files": [ @@ -8,16 +8,18 @@ "dist" ], "scripts": { - "prebuild": "tsc --build", - "build": "ncc build src/index.ts --minify --target es2021 --v8-cache", - "prettier": "prettier --ignore-path .gitignore \"./**/*.{js,json,md,ts,yml}\"" + "build": "npm run build:main && npm run build:post", + "build:main": "npm run compile -- --out ./dist/main src/main.ts ", + "build:post": "npm run compile -- --out ./dist/post src/post.ts", + "compile": "ncc build --minify --no-cache --target es2022 --v8-cache", + "prettier": "prettier --ignore-path .gitignore \"./**/*.{js,json,md,ts,yml}\"", + "typecheck": "tsc --build" }, "dependencies": { "@actions/core": "^1.10.0", "@actions/github": "^5.1.1", "@octokit/auth-app": "^6.0.0", "@octokit/request": "^8.1.1", - "ensure-error": "^4.0.0", "is-base64": "^1.1.0" }, "devDependencies": { diff --git a/src/create-installation-access-token.ts b/src/create-installation-access-token.ts new file mode 100644 index 00000000..2054fad5 --- /dev/null +++ b/src/create-installation-access-token.ts @@ -0,0 +1,59 @@ +import { getOctokit } from "@actions/github"; +import { createAppAuth } from "@octokit/auth-app"; +import { request } from "@octokit/request"; + +import { + InstallationRetrievalDetails, + retrieveInstallationId, +} from "./installation-retrieval.js"; + +export type InstallationAccessTokenCreationOptions = Readonly<{ + appId: string; + githubApiUrl: URL; + installationRetrievalDetails: InstallationRetrievalDetails; + permissions?: Record; + privateKey: string; + repositories?: string[]; +}>; + +export const createInstallationAccessToken = async ({ + appId, + githubApiUrl, + installationRetrievalDetails, + permissions, + privateKey, + repositories, +}: InstallationAccessTokenCreationOptions): Promise => { + try { + const app = createAppAuth({ + appId, + privateKey, + request: request.defaults({ + baseUrl: githubApiUrl + .toString() + // Remove optional trailing `/`. + .replace(/\/$/, ""), + }), + }); + + const authApp = await app({ type: "app" }); + const octokit = getOctokit(authApp.token); + + const installationId = await retrieveInstallationId( + installationRetrievalDetails, + { octokit }, + ); + + const { + data: { token }, + } = await octokit.request( + "POST /app/installations/{installation_id}/access_tokens", + { installation_id: installationId, permissions, repositories }, + ); + return token; + } catch (error: unknown) { + throw new Error("Could not create installation access token.", { + cause: error, + }); + } +}; diff --git a/src/fetch-installation-token.ts b/src/fetch-installation-token.ts deleted file mode 100644 index 746cf9b3..00000000 --- a/src/fetch-installation-token.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { getOctokit } from "@actions/github"; -import { createAppAuth } from "@octokit/auth-app"; -import { request } from "@octokit/request"; - -export const fetchInstallationToken = async ({ - appId, - githubApiUrl, - installationId, - owner, - permissions, - privateKey, - repo, -}: Readonly<{ - appId: string; - githubApiUrl: URL; - installationId?: number; - owner: string; - permissions?: Record; - privateKey: string; - repo: string; -}>): Promise => { - const app = createAppAuth({ - appId, - privateKey, - request: request.defaults({ - baseUrl: githubApiUrl - .toString() - // Remove optional trailing `/`. - .replace(/\/$/, ""), - }), - }); - - const authApp = await app({ type: "app" }); - const octokit = getOctokit(authApp.token); - - if (installationId === undefined) { - try { - ({ - data: { id: installationId }, - } = await octokit.rest.apps.getRepoInstallation({ owner, repo })); - } catch (error: unknown) { - throw new Error( - "Could not get repo installation. Is the app installed on this repo?", - { cause: error }, - ); - } - } - - try { - const { data: installation } = - await octokit.rest.apps.createInstallationAccessToken({ - installation_id: installationId, - permissions, - }); - return installation.token; - } catch (error: unknown) { - throw new Error("Could not create installation access token.", { - cause: error, - }); - } -}; diff --git a/src/index.ts b/src/index.ts deleted file mode 100644 index 6fd72843..00000000 --- a/src/index.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { Buffer } from "node:buffer"; - -import { getInput, info, setFailed, setOutput, setSecret } from "@actions/core"; -import ensureError from "ensure-error"; -import isBase64 from "is-base64"; - -import { fetchInstallationToken } from "./fetch-installation-token.js"; - -try { - const appId = getInput("app_id", { required: true }); - - const installationIdInput = getInput("installation_id"); - const installationId = installationIdInput - ? Number(installationIdInput) - : undefined; - - const permissionsInput = getInput("permissions"); - const permissions = permissionsInput - ? (JSON.parse(permissionsInput) as Record) - : undefined; - - const privateKeyInput = getInput("private_key", { required: true }); - const privateKey = isBase64(privateKeyInput) - ? Buffer.from(privateKeyInput, "base64").toString("utf8") - : privateKeyInput; - - const repositoryInput = getInput("repository", { required: true }); - const [owner, repo] = repositoryInput.split("/"); - - const githubApiUrlInput = getInput("github_api_url", { required: true }); - const githubApiUrl = new URL(githubApiUrlInput); - - const installationToken = await fetchInstallationToken({ - appId, - githubApiUrl, - installationId, - owner, - permissions, - privateKey, - repo, - }); - - setSecret(installationToken); - setOutput("token", installationToken); - info("Token generated successfully!"); -} catch (_error: unknown) { - const error = ensureError(_error); - setFailed(error); -} diff --git a/src/installation-retrieval.ts b/src/installation-retrieval.ts new file mode 100644 index 00000000..b4c6b08b --- /dev/null +++ b/src/installation-retrieval.ts @@ -0,0 +1,72 @@ +import { debug } from "@actions/core"; +import { getOctokit } from "@actions/github"; + +export type InstallationRetrievalDetails = Readonly< + | { mode: "id"; id: number } + | { mode: "organization"; org: string } + | { mode: "repository"; owner: string; repo: string } + | { mode: "user"; username: string } +>; + +export const getInstallationRetrievalDetails = ({ + mode, + payload, +}: Readonly<{ + mode: string; + payload: string; +}>): InstallationRetrievalDetails => { + switch (mode) { + case "id": + return { mode, id: parseInt(payload) }; + case "organization": + return { mode, org: payload }; + case "repository": + const [owner, repo] = payload.split("/"); + return { mode, owner, repo }; + case "user": + return { mode, username: payload }; + default: + throw new Error(`Unsupported retrieval mode: "${mode}".`); + } +}; + +export const retrieveInstallationId = async ( + details: InstallationRetrievalDetails, + { octokit }: Readonly<{ octokit: ReturnType }>, +): Promise => { + let id: number; + try { + switch (details.mode) { + case "id": + ({ id } = details); + break; + case "organization": + ({ + data: { id }, + } = await octokit.request("GET /orgs/{org}/installation", { + org: details.org, + })); + break; + case "repository": + ({ + data: { id }, + } = await octokit.request("GET /repos/{owner}/{repo}/installation", { + owner: details.owner, + repo: details.repo, + })); + break; + case "user": + ({ + data: { id }, + } = await octokit.request("GET /users/{username}/installation", { + username: details.username, + })); + break; + } + } catch (error: unknown) { + throw new Error("Could not retrieve installation.", { cause: error }); + } + + debug(`Retrieved installation ID: ${id}.`); + return id; +}; diff --git a/src/main.ts b/src/main.ts new file mode 100644 index 00000000..9a0f7f25 --- /dev/null +++ b/src/main.ts @@ -0,0 +1,15 @@ +import { info, saveState, setOutput, setSecret } from "@actions/core"; + +import { createInstallationAccessToken } from "./create-installation-access-token.js"; +import { parseOptions } from "./parse-options.js"; +import { run } from "./run.js"; +import { tokenKey } from "./state.js"; + +await run(async () => { + const options = parseOptions(); + const token = await createInstallationAccessToken(options); + setSecret(token); + saveState(tokenKey, token); + setOutput("token", token); + info("Token created successfully"); +}); diff --git a/src/parse-options.ts b/src/parse-options.ts new file mode 100644 index 00000000..07d2efd6 --- /dev/null +++ b/src/parse-options.ts @@ -0,0 +1,57 @@ +import { Buffer } from "node:buffer"; + +import { debug, getInput } from "@actions/core"; +import isBase64 from "is-base64"; + +import { InstallationAccessTokenCreationOptions } from "./create-installation-access-token.js"; +import { getInstallationRetrievalDetails } from "./installation-retrieval.js"; + +export const parseOptions = (): InstallationAccessTokenCreationOptions => { + const appId = getInput("app_id", { required: true }); + + const githubApiUrlInput = getInput("github_api_url", { required: true }); + const githubApiUrl = new URL(githubApiUrlInput); + + const installationRetrievalMode = getInput("installation_retrieval_mode", { + required: true, + }); + const installationRetrievalPayload = getInput( + "installation_retrieval_payload", + { required: true }, + ); + const installationRetrievalDetails = getInstallationRetrievalDetails({ + mode: installationRetrievalMode, + payload: installationRetrievalPayload, + }); + debug( + `Installation retrieval details: ${JSON.stringify( + installationRetrievalDetails, + )}.`, + ); + + const permissionsInput = getInput("permissions"); + const permissions = permissionsInput + ? (JSON.parse(permissionsInput) as Record) + : undefined; + debug(`Requested permissions: ${JSON.stringify(permissions)}.`); + + const privateKeyInput = getInput("private_key", { required: true }); + const privateKey = isBase64(privateKeyInput) + ? Buffer.from(privateKeyInput, "base64").toString("utf8") + : privateKeyInput; + + const repositoriesInput = getInput("repositories"); + const repositories = repositoriesInput + ? (JSON.parse(repositoriesInput) as string[]) + : undefined; + debug(`Requested repositories: ${JSON.stringify(repositories)}.`); + + return { + appId, + githubApiUrl, + installationRetrievalDetails, + permissions, + privateKey, + repositories, + }; +}; diff --git a/src/post.ts b/src/post.ts new file mode 100644 index 00000000..9ffc13ce --- /dev/null +++ b/src/post.ts @@ -0,0 +1,15 @@ +import { getState, info } from "@actions/core"; + +import { revokeInstallationAccessToken } from "./revoke-installation-access-token.js"; +import { run } from "./run.js"; +import { tokenKey } from "./state.js"; + +await run(async () => { + const token = getState(tokenKey); + if (!token) { + info("No token to revoke"); + return; + } + await revokeInstallationAccessToken(token); + info("Token revoked successfully"); +}); diff --git a/src/revoke-installation-access-token.ts b/src/revoke-installation-access-token.ts new file mode 100644 index 00000000..03e5dbb2 --- /dev/null +++ b/src/revoke-installation-access-token.ts @@ -0,0 +1,15 @@ +import { getOctokit } from "@actions/github"; + +export const revokeInstallationAccessToken = async ( + token: string, +): Promise => { + try { + const octokit = getOctokit(token); + + await octokit.request("DELETE /installation/token"); + } catch (error: unknown) { + throw new Error("Could not revoke installation access token.", { + cause: error, + }); + } +}; diff --git a/src/run.ts b/src/run.ts new file mode 100644 index 00000000..22ef55e0 --- /dev/null +++ b/src/run.ts @@ -0,0 +1,12 @@ +import { setFailed } from "@actions/core"; + +export const run = async (callback: () => Promise) => { + try { + await callback(); + } catch (error) { + // Using `console.error()` instead of only passing `error` to `setFailed()` for better error reporting. + // See https://github.com/actions/toolkit/issues/1527. + console.error(error); + setFailed(""); + } +}; diff --git a/src/state.ts b/src/state.ts new file mode 100644 index 00000000..ddd1ef32 --- /dev/null +++ b/src/state.ts @@ -0,0 +1 @@ +export const tokenKey = "token";