feat: Add a skip_token_revoke input for configuring token revocation (#54)

Fixes https://github.com/actions/create-github-app-token/issues/55

Currently, `actions/create-github-app-token` always/unconditionally
revokes the installation access token in a `post` step, at the
completion of the current job. This prevents tokens from being used in
other jobs.

This PR makes this behavior configurable:
- When the `skip-token-revoke` input is not specified (i.e. by default),
the token is revoked in a `post` step (i.e. the current behavior).
- When the `skip-token-revoke` input is set to a truthy value (e.g.
`"true"`[^1]), the token is not revoked in a `post` step.

This PR adds a test for the `skip-token-revoke: "true"` case.

This is configurable in other app token actions, e.g.
[tibdex/github-app-token](https://github.com/tibdex/github-app-token/blob/3eb77c7243b85c65e84acfa93fdbac02fb6bd532/README.md?plain=1#L46-L47)
and
[wow-actions/use-app-token](https://github.com/wow-actions/use-app-token/blob/cd772994fc762f99cf291f308797341327a49b0c/README.md?plain=1#L132).

[^1]: Note that `"false"` is also truthy: `Boolean("false")` is `true`.
If we think that’ll potentially confuse folks, I can require
`skip-token-revoke` to be set explicitly to `"true"`.
This commit is contained in:
Clay Miller
2023-10-06 12:10:49 -04:00
committed by GitHub
parent d400084c45
commit 9ec88c41ee
11 changed files with 77 additions and 8 deletions
+5 -1
View File
@@ -145,6 +145,10 @@ jobs:
> [!NOTE] > [!NOTE]
> If `owner` is set and `repositories` is empty, access will be scoped to all repositories in the provided repository owner's installation. If `owner` and `repositories` are empty, access will be scoped to only the current repository. > If `owner` is set and `repositories` is empty, access will be scoped to all repositories in the provided repository owner's installation. If `owner` and `repositories` are empty, access will be scoped to only the current repository.
### `skip_token_revoke`
**Optional:** If truthy, the token will not be revoked when the current job is complete.
## Outputs ## Outputs
### `token` ### `token`
@@ -158,7 +162,7 @@ The action creates an installation access token using [the `POST /app/installati
1. The token is scoped to the current repository or `repositories` if set. 1. The token is scoped to the current repository or `repositories` if set.
2. The token inherits all the installation's permissions. 2. The token inherits all the installation's permissions.
3. The token is set as output `token` which can be used in subsequent steps. 3. The token is set as output `token` which can be used in subsequent steps.
4. The token is revoked in the `post` step of the action, which means it cannot be passed to another job. 4. Unless the `skip_token_revoke` input is set to a truthy value, the token is revoked in the `post` step of the action, which means it cannot be passed to another job.
5. The token is masked, it cannot be logged accidentally. 5. The token is masked, it cannot be logged accidentally.
> [!NOTE] > [!NOTE]
+3
View File
@@ -17,6 +17,9 @@ inputs:
repositories: repositories:
description: "Repositories to install the GitHub App on (defaults to current repository if owner is unset)" description: "Repositories to install the GitHub App on (defaults to current repository if owner is unset)"
required: false required: false
skip_token_revoke:
description: "If truthy, the token will not be revoked when the current job is complete"
required: false
outputs: outputs:
token: token:
description: "GitHub installation access token" description: "GitHub installation access token"
+7 -3
View File
@@ -10006,7 +10006,7 @@ var import_core = __toESM(require_core(), 1);
var import_auth_app = __toESM(require_dist_node12(), 1); var import_auth_app = __toESM(require_dist_node12(), 1);
// lib/main.js // lib/main.js
async function main(appId2, privateKey2, owner2, repositories2, core2, createAppAuth2, request2) { async function main(appId2, privateKey2, owner2, repositories2, core2, createAppAuth2, request2, skipTokenRevoke2) {
let parsedOwner = ""; let parsedOwner = "";
let parsedRepositoryNames = ""; let parsedRepositoryNames = "";
if (!owner2 && !repositories2) { if (!owner2 && !repositories2) {
@@ -10082,7 +10082,9 @@ async function main(appId2, privateKey2, owner2, repositories2, core2, createApp
} }
core2.setSecret(authentication.token); core2.setSecret(authentication.token);
core2.setOutput("token", authentication.token); core2.setOutput("token", authentication.token);
core2.saveState("token", authentication.token); if (!skipTokenRevoke2) {
core2.saveState("token", authentication.token);
}
} }
// lib/request.js // lib/request.js
@@ -10105,6 +10107,7 @@ var appId = import_core.default.getInput("app_id");
var privateKey = import_core.default.getInput("private_key"); var privateKey = import_core.default.getInput("private_key");
var owner = import_core.default.getInput("owner"); var owner = import_core.default.getInput("owner");
var repositories = import_core.default.getInput("repositories"); var repositories = import_core.default.getInput("repositories");
var skipTokenRevoke = Boolean(import_core.default.getInput("skip_token_revoke"));
main( main(
appId, appId,
privateKey, privateKey,
@@ -10114,7 +10117,8 @@ main(
import_auth_app.createAppAuth, import_auth_app.createAppAuth,
request_default.defaults({ request_default.defaults({
baseUrl: process.env["GITHUB_API_URL"] baseUrl: process.env["GITHUB_API_URL"]
}) }),
skipTokenRevoke
).catch((error) => { ).catch((error) => {
console.error(error); console.error(error);
import_core.default.setFailed(error.message); import_core.default.setFailed(error.message);
+5
View File
@@ -2973,6 +2973,11 @@ var import_core = __toESM(require_core(), 1);
// lib/post.js // lib/post.js
async function post(core2, request2) { async function post(core2, request2) {
const skipTokenRevoke = Boolean(core2.getInput("skip_token_revoke"));
if (skipTokenRevoke) {
core2.info("Token revocation was skipped");
return;
}
const token = core2.getState("token"); const token = core2.getState("token");
if (!token) { if (!token) {
core2.info("Token is not set"); core2.info("Token is not set");
+6 -2
View File
@@ -8,6 +8,7 @@
* @param {import("@actions/core")} core * @param {import("@actions/core")} core
* @param {import("@octokit/auth-app").createAppAuth} createAppAuth * @param {import("@octokit/auth-app").createAppAuth} createAppAuth
* @param {import("@octokit/request").request} request * @param {import("@octokit/request").request} request
* @param {boolean} skipTokenRevoke
*/ */
export async function main( export async function main(
appId, appId,
@@ -16,7 +17,8 @@ export async function main(
repositories, repositories,
core, core,
createAppAuth, createAppAuth,
request request,
skipTokenRevoke
) { ) {
let parsedOwner = ""; let parsedOwner = "";
let parsedRepositoryNames = ""; let parsedRepositoryNames = "";
@@ -122,5 +124,7 @@ export async function main(
core.setOutput("token", authentication.token); core.setOutput("token", authentication.token);
// Make token accessible to post function (so we can invalidate it) // Make token accessible to post function (so we can invalidate it)
core.saveState("token", authentication.token); if (!skipTokenRevoke) {
core.saveState("token", authentication.token);
}
} }
+7
View File
@@ -5,6 +5,13 @@
* @param {import("@octokit/request").request} request * @param {import("@octokit/request").request} request
*/ */
export async function post(core, request) { export async function post(core, request) {
const skipTokenRevoke = Boolean(core.getInput("skip_token_revoke"));
if (skipTokenRevoke) {
core.info("Token revocation was skipped");
return;
}
const token = core.getState("token"); const token = core.getState("token");
if (!token) { if (!token) {
+4 -1
View File
@@ -19,6 +19,8 @@ const privateKey = core.getInput("private_key");
const owner = core.getInput("owner"); const owner = core.getInput("owner");
const repositories = core.getInput("repositories"); const repositories = core.getInput("repositories");
const skipTokenRevoke = Boolean(core.getInput("skip_token_revoke"));
main( main(
appId, appId,
privateKey, privateKey,
@@ -28,7 +30,8 @@ main(
createAppAuth, createAppAuth,
request.defaults({ request.defaults({
baseUrl: process.env["GITHUB_API_URL"], baseUrl: process.env["GITHUB_API_URL"],
}) }),
skipTokenRevoke
).catch((error) => { ).catch((error) => {
console.error(error); console.error(error);
core.setFailed(error.message); core.setFailed(error.message);
+1 -1
View File
@@ -6,7 +6,7 @@ Add one test file per scenario. You can run them in isolation with:
node tests/post-token-set.test.js node tests/post-token-set.test.js
``` ```
All tests are run together in [tests/index.js](index.js), which can be execauted with ava All tests are run together in [tests/index.js](index.js), which can be executed with ava
``` ```
npx ava tests/index.js npx ava tests/index.js
+29
View File
@@ -0,0 +1,29 @@
import { MockAgent, setGlobalDispatcher } from "undici";
// state variables are set as environment variables with the prefix STATE_
// https://docs.github.com/en/actions/using-workflows/workflow-commands-for-github-actions#sending-values-to-the-pre-and-post-actions
process.env.STATE_token = "secret123";
// inputs are set as environment variables with the prefix INPUT_
// https://docs.github.com/en/actions/creating-actions/metadata-syntax-for-github-actions#example-specifying-inputs
process.env.INPUT_SKIP_TOKEN_REVOKE = "true";
const mockAgent = new MockAgent();
setGlobalDispatcher(mockAgent);
// Provide the base url to the request
const mockPool = mockAgent.get("https://api.github.com");
// intercept the request
mockPool
.intercept({
path: "/installation/token",
method: "DELETE",
headers: {
authorization: "token secret123",
},
})
.reply(204);
await import("../post.js");
+10
View File
@@ -14,6 +14,16 @@ Generated by [AVA](https://avajs.dev).
'Token revoked' 'Token revoked'
## post-token-skipped.test.js
> stderr
''
> stdout
'Token revocation was skipped'
## post-token-unset.test.js ## post-token-unset.test.js
> stderr > stderr
Binary file not shown.