From febe21311bcfc1efdcc80ac772065f3a3e21497a Mon Sep 17 00:00:00 2001 From: Seth Vargo Date: Tue, 21 Sep 2021 18:10:27 -0400 Subject: [PATCH] Add the ability to generate and export a credentials file (#7) This credentials file can be passed to gcloud or other Google Cloud SDKs to automatically do the exchange. --- .github/workflows/test.yaml | 69 +++++++++++---- README.md | 172 ++++++++++++++++++++++++++++++++---- action.yml | 37 ++++++-- dist/index.js | 129 ++++++++++++++++++++++++--- package-lock.json | 160 ++++++++++++++++----------------- src/client.ts | 70 +++++++++++++++ src/main.ts | 87 ++++++++++++++---- 7 files changed, 571 insertions(+), 153 deletions(-) diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 13fdf20..bfff2a7 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -16,7 +16,7 @@ jobs: steps: - uses: 'actions/checkout@v2' - - uses: 'actions/setup-node@master' + - uses: 'actions/setup-node@v2' with: node-version: '12.x' @@ -29,11 +29,11 @@ jobs: - name: 'npm test' run: 'npm run test' - access_token: - name: 'access_token' + credentials_file: + name: 'credentials_file' permissions: - id-token: write - contents: read + id-token: 'write' + contents: 'read' runs-on: '${{ matrix.operating-system }}' strategy: fail-fast: false @@ -42,15 +42,50 @@ jobs: - 'ubuntu-latest' - 'windows-latest' - 'macos-latest' + steps: - uses: 'actions/checkout@v2' - - uses: 'actions/setup-node@master' + - uses: 'actions/setup-node@v2' with: node-version: '12.x' - - id: 'access-token' - name: 'integration' + - uses: 'google-github-actions/setup-gcloud@master' + with: + project_id: 'actions-oidc-test' + + - id: 'auth' + name: 'auth' + uses: './' + with: + create_credentials_file: true + workload_identity_provider: 'projects/469401941463/locations/global/workloadIdentityPools/github-actions/providers/github-oidc-auth-google-cloud' + service_account: 'github-secret-accessor@actions-oidc-test.iam.gserviceaccount.com' + + - id: 'gcloud' + name: 'gcloud' + run: |- + gcloud auth login --brief --cred-file="${{ steps.auth.outputs.credentials_file_path }}" + gcloud secrets versions access "latest" --secret "my-secret" + + access_token: + name: 'access_token' + permissions: + id-token: 'write' + contents: 'read' + runs-on: 'ubuntu-latest' + strategy: + fail-fast: false + + steps: + - uses: 'actions/checkout@v2' + + - uses: 'actions/setup-node@v2' + with: + node-version: '12.x' + + - id: 'auth' + name: 'auth' uses: './' with: token_format: 'access_token' @@ -60,25 +95,21 @@ jobs: id_token: name: 'id_token' permissions: - id-token: write - contents: read - runs-on: '${{ matrix.operating-system }}' + id-token: 'write' + contents: 'read' + runs-on: 'ubuntu-latest' strategy: fail-fast: false - matrix: - operating-system: - - 'ubuntu-latest' - - 'windows-latest' - - 'macos-latest' + steps: - uses: 'actions/checkout@v2' - - uses: 'actions/setup-node@master' + - uses: 'actions/setup-node@v2' with: node-version: '12.x' - - id: 'id-token' - name: 'integration' + - id: 'auth' + name: 'auth' uses: './' with: token_format: 'id_token' diff --git a/README.md b/README.md index 90d05e8..ff82d1b 100644 --- a/README.md +++ b/README.md @@ -19,11 +19,13 @@ and permissions on Google Cloud. 1. Exchange the GitHub Actions OIDC token for a short-lived Google Cloud access token + ## Prerequisites - This action requires you to create and configure a Google Cloud Workload Identity Provider. See [#setup](#setup) for instructions. + ## Usage ```yaml @@ -33,11 +35,10 @@ jobs: # Add "id-token" with the intended permissions. permissions: - id-token: write - contents: read + id-token: 'write' steps: - - id: 'google-cloud-auth' + - id: 'auth' name: 'Authenticate to Google Cloud' uses: 'sethvargo/oidc-auth-google-cloud@v0.2.0' with: @@ -45,13 +46,16 @@ jobs: workload_identity_provider: 'projects/123456789/locations/global/workloadIdentityPools/my-pool/providers/my-provider' service_account: 'my-service-account@my-project.iam.gserviceaccount.com' - # Example of using the output: - - id: 'access-secret' + # Example of using the token: + - name: 'Access secret' run: |- curl https://secretmanager.googleapis.com/v1/projects/my-project/secrets/my-secret/versions/1:access \ - --header "Authorization: Bearer ${{ steps.google-cloud-auth.outputs.access_token }}" + --header "Authorization: Bearer ${{ steps.auth.outputs.access_token }}" ``` +See [Examples](#examples) for more examples. + + ## Inputs - `workload_identity_provider`: (Required) The full identifier of the Workload @@ -75,9 +79,24 @@ jobs: `"sigstore"`, but this variable exists in case custom values are permitted in the future. The default value is `"sigstore"`. -- `token_format`: (Optional) Format of the generated token. For OAuth 2.0 - access tokens, specify "access_token". For OIDC tokens, specify "id_token". - The default value is "access_token". +- `create_credentials_file`: (Optional) If true, the action will securely + generate a credentials file which can be used for authentication via gcloud + and Google Cloud SDKs. The default is false. + +- `activate_credentials_file`: (Optional) If true and + "create_credentials_file" is also true, this will set the + `GOOGLE_APPLICATION_CREDENTIALS` environment variable to the path to the + credentials file, which gcloud and Google Cloud SDKs automatically consume. + The default value is true. + +- `token_format`: (Optional) Output format for the generated authentication + token. + + - For OAuth 2.0 access tokens, specify "access_token". + - For OIDC tokens, specify "id_token". + - To skip token generation, omit or set to the empty string "". + + The default value is "" (skip token creation). - `delegates`: (Optional) List of additional service account emails or unique identities to use for impersonation in the chain. By default there are no @@ -95,23 +114,144 @@ jobs: https://www.googleapis.com/auth/cloud-platform ``` -- `id_token_audience`: (Optional) The audience for the generated ID Token. +- `id_token_audience`: (Required\*) The audience for the generated ID Token. + This option is required when "token_format" is "id_token", but otherwise can + be omitted. - `id_token_include_email`: (Optional) Optional parameter of whether to include the service account email in the generated token. If true, the token will contain "email" and "email_verified" claims. This is only valid when "token_format" is "access_token". The default value is false. + ## Outputs -- `access_token`: The authenticated Google Cloud access token for calling - other Google Cloud APIs. +- `credentials_file_path`: Path on the local filesystem where the generated + credentials file resides. This is only available if + "create_credentials_file" was set to true. -- `access_token_expiration`: The RFC3339 UTC "Zulu" format timestamp when the - token expires. +- `access_token`: The Google Cloud access token for calling other Google Cloud + APIs. This is only available when "token_format" is "access_token". + +- `access_token_expiration`: The RFC3339 UTC "Zulu" format timestamp for the + access token. This is only available when "token_format" is "access_token". + +- `id_token`: The Google Cloud ID token. This is only available when + "token_format" is "id_token". + +## Examples + +#### Cloud SDK (gcloud) + +This example demonstrates using this GitHub Action to configure authentication +for the `gcloud` CLI tool. Note this does **not** work for the `gsutil` tool. + +```yaml +jobs: + run: + # ... + + # Add "id-token" with the intended permissions. + permissions: + id-token: 'write' + + steps: + # Install gcloud, do not specify authentication. + - uses: 'google-github-actions/setup-gcloud@master' + with: + project_id: 'my-project' + + # Configure Workload Identity Federation via a credentials file. + - id: 'auth' + name: 'Authenticate to Google Cloud' + uses: 'sethvargo/oidc-auth-google-cloud@v0.2.0' + with: + create_credentials_file: 'access_token' + workload_identity_provider: 'projects/123456789/locations/global/workloadIdentityPools/my-pool/providers/my-provider' + service_account: 'my-service-account@my-project.iam.gserviceaccount.com' + + # Authenticate using the created credentials file. + - id: 'gcloud' + name: 'gcloud' + run: |- + gcloud auth login --brief --cred-file="${{ steps.auth.outputs.credentials_file_path }}" + + # Now you can run gcloud commands authenticated as the impersonated service account. + gcloud secrets versions access "latest" --secret "my-secret" +``` + +#### Access Token (OAuth 2.0) + +This example demonstrates using this GitHub Action to generate an OAuth 2.0 +Access Token for authenticating to Google Cloud. Most Google Cloud APIs accept +this access token as authentication. + +The default lifetime is 1 hour, but you can request up to 12 hours if you set +the [`constraints/iam.allowServiceAccountCredentialLifetimeExtension` organization policy](https://cloud.google.com/resource-manager/docs/organization-policy/org-policy-constraints). + +```yaml +jobs: + run: + # ... + + # Add "id-token" with the intended permissions. + permissions: + id-token: 'write' + + steps: + # Configure Workload Identity Federation and generate an access token. + - id: 'auth' + name: 'Authenticate to Google Cloud' + uses: 'sethvargo/oidc-auth-google-cloud@v0.2.0' + with: + token_format: 'access_token' + workload_identity_provider: 'projects/123456789/locations/global/workloadIdentityPools/my-pool/providers/my-provider' + service_account: 'my-service-account@my-project.iam.gserviceaccount.com' + access_token_lifetime: '300s' # optional, default: '3600s' (1 hour) + + # Example of using the output. The token is usually provided as a Bearer + # token. + - id: 'access-secret' + run: |- + curl https://secretmanager.googleapis.com/v1/projects/my-project/secrets/my-secret/versions/1:access \ + --header "Authorization: Bearer ${{ steps.auth.outputs.access_token }}" +``` + +#### ID Token (JWT) + +This example demonstrates using this GitHub Action to generate a Google Cloud ID +Token for authenticating to Google Cloud. This is most commonly used when +invoking a Cloud Run service. + +```yaml +jobs: + run: + # ... + + # Add "id-token" with the intended permissions. + permissions: + id-token: 'write' + + steps: + # Configure Workload Identity Federation and generate an access token. + - id: 'auth' + name: 'Authenticate to Google Cloud' + uses: 'sethvargo/oidc-auth-google-cloud@v0.2.0' + with: + token_format: 'access_token' + workload_identity_provider: 'projects/123456789/locations/global/workloadIdentityPools/my-pool/providers/my-provider' + service_account: 'my-service-account@my-project.iam.gserviceaccount.com' + id_token_audience: 'https://myapp-uvehjacqzq.a.run.app' # required, value depends on target + id_token_include_email: true # optional + + # Example of using the output. The token is usually provided as a Bearer + # token. + - id: 'invoke-service' + run: |- + curl https://myapp-uvehjacqzq.a.run.app \ + --header "Authorization: Bearer ${{ steps.auth.outputs.id_token }}" +``` -- `id_token`: The authenticated Google Cloud ID token. This token is only - generated when `id_token_audience` input parameter is provided. ## Setup diff --git a/action.yml b/action.yml index 76604f5..5bccb2f 100644 --- a/action.yml +++ b/action.yml @@ -38,12 +38,27 @@ inputs: exists in case custom values are permitted in the future. default: 'sigstore' required: false + create_credentials_file: + description: |- + If true, the action will securely generate a credentials file which can be + used for authentication via gcloud and Google Cloud SDKs. + default: false + required: false + activate_credentials_file: + description: |- + If true and create_credentials_file is also true, this will set the + GOOGLE_APPLICATION_CREDENTIALS environment variable to the path to the + credentials file, which gcloud and Google Cloud SDKs automatically + consume. + default: true + required: false token_format: description: |- - Format for the generated token. For OAuth 2.0 access tokens, specify - "access_token". For OIDC tokens, specify "id_token". - default: 'access_token' - required: true + Output format for the generated authentication token. For OAuth 2.0 access + tokens, specify "access_token". For OIDC tokens, specify "id_token". To + skip token generation, leave this value empty. + default: '' + required: false delegates: description: |- List of additional service account emails or unique identities to use for @@ -78,19 +93,23 @@ inputs: Optional parameter of whether to include the service account email in the generated token. If true, the token will contain "email" and "email_verified" claims. This is only valid when "token_format" is - "access_token". + "id_token". default: false required: false outputs: + credentials_file_path: + description: |- + Path on the local filesystem where the generated credentials file resides. + This is only available if "create_credentials_file" was set to true. access_token: description: |- - The Google Cloud access token for calling other Google Cloud APIs. This - is only available when "token_format" is "access_token". + The Google Cloud access token for calling other Google Cloud APIs. This is + only available when "token_format" is "access_token". access_token_expiration: description: |- - The expiration timestamp for the access token. This is only available - when "token_format" is "access_token". + The RFC3339 UTC "Zulu" format timestamp for the access token. This is only + available when "token_format" is "access_token". id_token: description: |- The Google Cloud ID token. This is only available when "token_format" is diff --git a/dist/index.js b/dist/index.js index ef58b4c..5a0be48 100644 --- a/dist/index.js +++ b/dist/index.js @@ -194,6 +194,7 @@ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, ge Object.defineProperty(exports, "__esModule", { value: true }); const core = __importStar(__webpack_require__(470)); const client_1 = __webpack_require__(976); +const url_1 = __webpack_require__(835); /** * Converts a multi-line or comma-separated collection of strings into an array * of trimmed strings. @@ -225,23 +226,70 @@ function run() { }); const serviceAccount = core.getInput('service_account', { required: true }); const audience = core.getInput('audience'); - const tokenFormat = core.getInput('token_format', { required: true }); + const createCredentialsFile = core.getBooleanInput('create_credentials_file'); + const activateCredentialsFile = core.getBooleanInput('activate_credentials_file'); + const tokenFormat = core.getInput('token_format'); const delegates = explodeStrings(core.getInput('delegates')); - const accessTokenLifetime = core.getInput('access_token_lifetime'); - const accessTokenScopes = explodeStrings(core.getInput('access_token_scopes')); - const idTokenAudience = core.getInput('id_token_audience'); - const idTokenIncludeEmail = core.getBooleanInput('id_token_include_email'); - // Get the GitHub OIDC token. - const githubOIDCToken = yield core.getIDToken(audience); - // Exchange the GitHub OIDC token for a Google Federated Token. - const googleFederatedToken = yield client_1.Client.googleFederatedToken({ - providerID: workloadIdentityProvider, - token: githubOIDCToken, + // Always write the credentials file first, before trying to generate + // tokens. This will ensure the file is written even if token generation + // fails, which means continue-on-error actions will still have the file + // available. + if (createCredentialsFile) { + const runnerTempDir = process.env.RUNNER_TEMP; + // Extract the request token and request URL from the environment. These + // are only set when an id-token is requested and the submitter has + // collaborator permissions. + const requestToken = process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN; + if (!requestToken) { + throw new Error('$ACTIONS_ID_TOKEN_REQUEST_TOKEN is not set'); + } + const requestURLRaw = process.env.ACTIONS_ID_TOKEN_REQUEST_URL; + if (!requestURLRaw) { + throw new Error('$ACTIONS_ID_TOKEN_REQUEST_URL is not set'); + } + const requestURL = new url_1.URL(requestURLRaw); + // Append the audience value to the request. + const params = requestURL.searchParams; + params.set('audience', audience); + requestURL.search = params.toString(); + // Create the credentials file. + const outputPath = yield client_1.Client.createCredentialsFile({ + providerID: workloadIdentityProvider, + serviceAccount: serviceAccount, + requestToken: requestToken, + requestURL: requestURL.toString(), + outputDir: runnerTempDir, + }); + core.setOutput('credentials_file_path', outputPath); + // Also set the magic environment variable for gcloud and SDKs if + // requested. + if (activateCredentialsFile) { + core.exportVariable('GOOGLE_APPLICATION_CREDENTIALS', outputPath); + } + } + // getFederatedToken is a closure that gets the federated token. + const getFederatedToken = () => __awaiter(this, void 0, void 0, function* () { + // Get the GitHub OIDC token. + const githubOIDCToken = yield core.getIDToken(audience); + // Exchange the GitHub OIDC token for a Google Federated Token. + const googleFederatedToken = yield client_1.Client.googleFederatedToken({ + providerID: workloadIdentityProvider, + token: githubOIDCToken, + }); + core.setSecret(googleFederatedToken); + return googleFederatedToken; }); - core.setSecret(googleFederatedToken); switch (tokenFormat) { + case '': { + break; + } + case null: { + break; + } case 'access_token': { - // Exchange the Google Federated Token for an access token. + const accessTokenLifetime = core.getInput('access_token_lifetime'); + const accessTokenScopes = explodeStrings(core.getInput('access_token_scopes')); + const googleFederatedToken = yield getFederatedToken(); const { accessToken, expiration } = yield client_1.Client.googleAccessToken({ token: googleFederatedToken, serviceAccount: serviceAccount, @@ -255,7 +303,9 @@ function run() { break; } case 'id_token': { - // Exchange the Google Federated Token for an id token. + const idTokenAudience = core.getInput('id_token_audience', { required: true }); + const idTokenIncludeEmail = core.getBooleanInput('id_token_include_email'); + const googleFederatedToken = yield getFederatedToken(); const { token } = yield client_1.Client.googleIDToken({ token: googleFederatedToken, serviceAccount: serviceAccount, @@ -640,6 +690,13 @@ module.exports = require("assert"); module.exports = __webpack_require__(141); +/***/ }), + +/***/ 417: +/***/ (function(module) { + +module.exports = require("crypto"); + /***/ }), /***/ 431: @@ -1823,6 +1880,9 @@ var __importDefault = (this && this.__importDefault) || function (mod) { Object.defineProperty(exports, "__esModule", { value: true }); exports.Client = void 0; const https_1 = __importDefault(__webpack_require__(211)); +const fs_1 = __webpack_require__(747); +const crypto_1 = __importDefault(__webpack_require__(417)); +const path_1 = __importDefault(__webpack_require__(622)); const url_1 = __webpack_require__(835); class Client { /** @@ -1830,6 +1890,12 @@ class Client { * request. */ static request(opts, data) { + if (!opts.headers) { + opts.headers = {}; + } + if (!opts.headers['User-Agent']) { + opts.headers['User-Agent'] = 'sethvargo:oidc-auth-google-cloud/0.2.1'; + } return new Promise((resolve, reject) => { const req = https_1.default.request(opts, (res) => { res.setEncoding('utf8'); @@ -1963,6 +2029,41 @@ class Client { } }); } + /** + * createCredentialsFile creates a Google Cloud credentials file that can be + * set as GOOGLE_APPLICATION_CREDENTIALS for gcloud and client libraries. + */ + static createCredentialsFile({ providerID, serviceAccount, requestToken, requestURL, outputDir, }) { + return __awaiter(this, void 0, void 0, function* () { + const data = { + type: 'external_account', + audience: `//iam.googleapis.com/${providerID}`, + subject_token_type: 'urn:ietf:params:oauth:token-type:jwt', + token_url: 'https://sts.googleapis.com/v1/token', + service_account_impersonation_url: `https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/${serviceAccount}:generateAccessToken`, + credential_source: { + url: requestURL, + headers: { + Authorization: `Bearer ${requestToken}`, + }, + format: { + type: 'json', + subject_token_field_name: 'value', + }, + }, + }; + // Generate a random filename to store the credential. 12 bytes is 24 + // characters in hex. It's not the ideal entropy, but we have to be under + // the 255 character limit for Windows filenames (which includes their + // entire leading path). + const uniqueName = crypto_1.default.randomBytes(12).toString('hex'); + const pth = path_1.default.join(outputDir, uniqueName); + // Write the file as 0640 so the owner has RW, group as R, and the file is + // otherwise unreadable. Also write with EXCL to prevent a symlink attack. + yield fs_1.promises.writeFile(pth, JSON.stringify(data), { mode: 0o640, flag: 'wx' }); + return pth; + }); + } } exports.Client = Client; diff --git a/package-lock.json b/package-lock.json index 8919fb8..a588f1b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -269,9 +269,9 @@ "dev": true }, "node_modules/@types/chai": { - "version": "4.2.21", - "resolved": "https://registry.npmjs.org/@types/chai/-/chai-4.2.21.tgz", - "integrity": "sha512-yd+9qKmJxm496BOV9CMNaey8TWsikaZOwMRwPHQIjcOJM9oV+fi9ZMNw3JsVnbEEbo2gRTDnGEBv8pjyn67hNg==", + "version": "4.2.22", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-4.2.22.tgz", + "integrity": "sha512-tFfcE+DSTzWAgifkjik9AySNqIyNoYwmR+uecPwwD/XRNfvOjmC/FjCxpiUGDkDVDphPfCUecSQVFw+lN3M3kQ==", "dev": true }, "node_modules/@types/json-schema": { @@ -287,19 +287,19 @@ "dev": true }, "node_modules/@types/node": { - "version": "16.9.2", - "resolved": "https://registry.npmjs.org/@types/node/-/node-16.9.2.tgz", - "integrity": "sha512-ZHty/hKoOLZvSz6BtP1g7tc7nUeJhoCf3flLjh8ZEv1vFKBWHXcnMbJMyN/pftSljNyy0kNW/UqI3DccnBnZ8w==", + "version": "16.9.6", + "resolved": "https://registry.npmjs.org/@types/node/-/node-16.9.6.tgz", + "integrity": "sha512-YHUZhBOMTM3mjFkXVcK+WwAcYmyhe1wL4lfqNtzI0b3qAy7yuSetnM7QJazgE5PFmgVTNGiLOgRFfJMqW7XpSQ==", "dev": true }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "4.31.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-4.31.1.tgz", - "integrity": "sha512-UDqhWmd5i0TvPLmbK5xY3UZB0zEGseF+DHPghZ37Sb83Qd3p8ujhvAtkU4OF46Ka5Pm5kWvFIx0cCTBFKo0alA==", + "version": "4.31.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-4.31.2.tgz", + "integrity": "sha512-w63SCQ4bIwWN/+3FxzpnWrDjQRXVEGiTt9tJTRptRXeFvdZc/wLiz3FQUwNQ2CVoRGI6KUWMNUj/pk63noUfcA==", "dev": true, "dependencies": { - "@typescript-eslint/experimental-utils": "4.31.1", - "@typescript-eslint/scope-manager": "4.31.1", + "@typescript-eslint/experimental-utils": "4.31.2", + "@typescript-eslint/scope-manager": "4.31.2", "debug": "^4.3.1", "functional-red-black-tree": "^1.0.1", "regexpp": "^3.1.0", @@ -324,15 +324,15 @@ } }, "node_modules/@typescript-eslint/experimental-utils": { - "version": "4.31.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/experimental-utils/-/experimental-utils-4.31.1.tgz", - "integrity": "sha512-NtoPsqmcSsWty0mcL5nTZXMf7Ei0Xr2MT8jWjXMVgRK0/1qeQ2jZzLFUh4QtyJ4+/lPUyMw5cSfeeME+Zrtp9Q==", + "version": "4.31.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/experimental-utils/-/experimental-utils-4.31.2.tgz", + "integrity": "sha512-3tm2T4nyA970yQ6R3JZV9l0yilE2FedYg8dcXrTar34zC9r6JB7WyBQbpIVongKPlhEMjhQ01qkwrzWy38Bk1Q==", "dev": true, "dependencies": { "@types/json-schema": "^7.0.7", - "@typescript-eslint/scope-manager": "4.31.1", - "@typescript-eslint/types": "4.31.1", - "@typescript-eslint/typescript-estree": "4.31.1", + "@typescript-eslint/scope-manager": "4.31.2", + "@typescript-eslint/types": "4.31.2", + "@typescript-eslint/typescript-estree": "4.31.2", "eslint-scope": "^5.1.1", "eslint-utils": "^3.0.0" }, @@ -348,14 +348,14 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "4.31.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-4.31.1.tgz", - "integrity": "sha512-dnVZDB6FhpIby6yVbHkwTKkn2ypjVIfAR9nh+kYsA/ZL0JlTsd22BiDjouotisY3Irmd3OW1qlk9EI5R8GrvRQ==", + "version": "4.31.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-4.31.2.tgz", + "integrity": "sha512-EcdO0E7M/sv23S/rLvenHkb58l3XhuSZzKf6DBvLgHqOYdL6YFMYVtreGFWirxaU2mS1GYDby3Lyxco7X5+Vjw==", "dev": true, "dependencies": { - "@typescript-eslint/scope-manager": "4.31.1", - "@typescript-eslint/types": "4.31.1", - "@typescript-eslint/typescript-estree": "4.31.1", + "@typescript-eslint/scope-manager": "4.31.2", + "@typescript-eslint/types": "4.31.2", + "@typescript-eslint/typescript-estree": "4.31.2", "debug": "^4.3.1" }, "engines": { @@ -375,13 +375,13 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "4.31.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-4.31.1.tgz", - "integrity": "sha512-N1Uhn6SqNtU2XpFSkD4oA+F0PfKdWHyr4bTX0xTj8NRx1314gBDRL1LUuZd5+L3oP+wo6hCbZpaa1in6SwMcVQ==", + "version": "4.31.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-4.31.2.tgz", + "integrity": "sha512-2JGwudpFoR/3Czq6mPpE8zBPYdHWFGL6lUNIGolbKQeSNv4EAiHaR5GVDQaLA0FwgcdcMtRk+SBJbFGL7+La5w==", "dev": true, "dependencies": { - "@typescript-eslint/types": "4.31.1", - "@typescript-eslint/visitor-keys": "4.31.1" + "@typescript-eslint/types": "4.31.2", + "@typescript-eslint/visitor-keys": "4.31.2" }, "engines": { "node": "^8.10.0 || ^10.13.0 || >=11.10.1" @@ -392,9 +392,9 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "4.31.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-4.31.1.tgz", - "integrity": "sha512-kixltt51ZJGKENNW88IY5MYqTBA8FR0Md8QdGbJD2pKZ+D5IvxjTYDNtJPDxFBiXmka2aJsITdB1BtO1fsgmsQ==", + "version": "4.31.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-4.31.2.tgz", + "integrity": "sha512-kWiTTBCTKEdBGrZKwFvOlGNcAsKGJSBc8xLvSjSppFO88AqGxGNYtF36EuEYG6XZ9vT0xX8RNiHbQUKglbSi1w==", "dev": true, "engines": { "node": "^8.10.0 || ^10.13.0 || >=11.10.1" @@ -405,13 +405,13 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "4.31.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-4.31.1.tgz", - "integrity": "sha512-EGHkbsUvjFrvRnusk6yFGqrqMBTue5E5ROnS5puj3laGQPasVUgwhrxfcgkdHNFECHAewpvELE1Gjv0XO3mdWg==", + "version": "4.31.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-4.31.2.tgz", + "integrity": "sha512-ieBq8U9at6PvaC7/Z6oe8D3czeW5d//Fo1xkF/s9394VR0bg/UaMYPdARiWyKX+lLEjY3w/FNZJxitMsiWv+wA==", "dev": true, "dependencies": { - "@typescript-eslint/types": "4.31.1", - "@typescript-eslint/visitor-keys": "4.31.1", + "@typescript-eslint/types": "4.31.2", + "@typescript-eslint/visitor-keys": "4.31.2", "debug": "^4.3.1", "globby": "^11.0.3", "is-glob": "^4.0.1", @@ -432,12 +432,12 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "4.31.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-4.31.1.tgz", - "integrity": "sha512-PCncP8hEqKw6SOJY+3St4LVtoZpPPn+Zlpm7KW5xnviMhdqcsBty4Lsg4J/VECpJjw1CkROaZhH4B8M1OfnXTQ==", + "version": "4.31.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-4.31.2.tgz", + "integrity": "sha512-PrBId7EQq2Nibns7dd/ch6S6/M4/iwLM9McbgeEbCXfxdwRUNxJ4UNreJ6Gh3fI2GNKNrWnQxKL7oCPmngKBug==", "dev": true, "dependencies": { - "@typescript-eslint/types": "4.31.1", + "@typescript-eslint/types": "4.31.2", "eslint-visitor-keys": "^2.0.0" }, "engines": { @@ -2888,9 +2888,9 @@ "dev": true }, "@types/chai": { - "version": "4.2.21", - "resolved": "https://registry.npmjs.org/@types/chai/-/chai-4.2.21.tgz", - "integrity": "sha512-yd+9qKmJxm496BOV9CMNaey8TWsikaZOwMRwPHQIjcOJM9oV+fi9ZMNw3JsVnbEEbo2gRTDnGEBv8pjyn67hNg==", + "version": "4.2.22", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-4.2.22.tgz", + "integrity": "sha512-tFfcE+DSTzWAgifkjik9AySNqIyNoYwmR+uecPwwD/XRNfvOjmC/FjCxpiUGDkDVDphPfCUecSQVFw+lN3M3kQ==", "dev": true }, "@types/json-schema": { @@ -2906,19 +2906,19 @@ "dev": true }, "@types/node": { - "version": "16.9.2", - "resolved": "https://registry.npmjs.org/@types/node/-/node-16.9.2.tgz", - "integrity": "sha512-ZHty/hKoOLZvSz6BtP1g7tc7nUeJhoCf3flLjh8ZEv1vFKBWHXcnMbJMyN/pftSljNyy0kNW/UqI3DccnBnZ8w==", + "version": "16.9.6", + "resolved": "https://registry.npmjs.org/@types/node/-/node-16.9.6.tgz", + "integrity": "sha512-YHUZhBOMTM3mjFkXVcK+WwAcYmyhe1wL4lfqNtzI0b3qAy7yuSetnM7QJazgE5PFmgVTNGiLOgRFfJMqW7XpSQ==", "dev": true }, "@typescript-eslint/eslint-plugin": { - "version": "4.31.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-4.31.1.tgz", - "integrity": "sha512-UDqhWmd5i0TvPLmbK5xY3UZB0zEGseF+DHPghZ37Sb83Qd3p8ujhvAtkU4OF46Ka5Pm5kWvFIx0cCTBFKo0alA==", + "version": "4.31.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-4.31.2.tgz", + "integrity": "sha512-w63SCQ4bIwWN/+3FxzpnWrDjQRXVEGiTt9tJTRptRXeFvdZc/wLiz3FQUwNQ2CVoRGI6KUWMNUj/pk63noUfcA==", "dev": true, "requires": { - "@typescript-eslint/experimental-utils": "4.31.1", - "@typescript-eslint/scope-manager": "4.31.1", + "@typescript-eslint/experimental-utils": "4.31.2", + "@typescript-eslint/scope-manager": "4.31.2", "debug": "^4.3.1", "functional-red-black-tree": "^1.0.1", "regexpp": "^3.1.0", @@ -2927,55 +2927,55 @@ } }, "@typescript-eslint/experimental-utils": { - "version": "4.31.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/experimental-utils/-/experimental-utils-4.31.1.tgz", - "integrity": "sha512-NtoPsqmcSsWty0mcL5nTZXMf7Ei0Xr2MT8jWjXMVgRK0/1qeQ2jZzLFUh4QtyJ4+/lPUyMw5cSfeeME+Zrtp9Q==", + "version": "4.31.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/experimental-utils/-/experimental-utils-4.31.2.tgz", + "integrity": "sha512-3tm2T4nyA970yQ6R3JZV9l0yilE2FedYg8dcXrTar34zC9r6JB7WyBQbpIVongKPlhEMjhQ01qkwrzWy38Bk1Q==", "dev": true, "requires": { "@types/json-schema": "^7.0.7", - "@typescript-eslint/scope-manager": "4.31.1", - "@typescript-eslint/types": "4.31.1", - "@typescript-eslint/typescript-estree": "4.31.1", + "@typescript-eslint/scope-manager": "4.31.2", + "@typescript-eslint/types": "4.31.2", + "@typescript-eslint/typescript-estree": "4.31.2", "eslint-scope": "^5.1.1", "eslint-utils": "^3.0.0" } }, "@typescript-eslint/parser": { - "version": "4.31.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-4.31.1.tgz", - "integrity": "sha512-dnVZDB6FhpIby6yVbHkwTKkn2ypjVIfAR9nh+kYsA/ZL0JlTsd22BiDjouotisY3Irmd3OW1qlk9EI5R8GrvRQ==", + "version": "4.31.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-4.31.2.tgz", + "integrity": "sha512-EcdO0E7M/sv23S/rLvenHkb58l3XhuSZzKf6DBvLgHqOYdL6YFMYVtreGFWirxaU2mS1GYDby3Lyxco7X5+Vjw==", "dev": true, "requires": { - "@typescript-eslint/scope-manager": "4.31.1", - "@typescript-eslint/types": "4.31.1", - "@typescript-eslint/typescript-estree": "4.31.1", + "@typescript-eslint/scope-manager": "4.31.2", + "@typescript-eslint/types": "4.31.2", + "@typescript-eslint/typescript-estree": "4.31.2", "debug": "^4.3.1" } }, "@typescript-eslint/scope-manager": { - "version": "4.31.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-4.31.1.tgz", - "integrity": "sha512-N1Uhn6SqNtU2XpFSkD4oA+F0PfKdWHyr4bTX0xTj8NRx1314gBDRL1LUuZd5+L3oP+wo6hCbZpaa1in6SwMcVQ==", + "version": "4.31.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-4.31.2.tgz", + "integrity": "sha512-2JGwudpFoR/3Czq6mPpE8zBPYdHWFGL6lUNIGolbKQeSNv4EAiHaR5GVDQaLA0FwgcdcMtRk+SBJbFGL7+La5w==", "dev": true, "requires": { - "@typescript-eslint/types": "4.31.1", - "@typescript-eslint/visitor-keys": "4.31.1" + "@typescript-eslint/types": "4.31.2", + "@typescript-eslint/visitor-keys": "4.31.2" } }, "@typescript-eslint/types": { - "version": "4.31.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-4.31.1.tgz", - "integrity": "sha512-kixltt51ZJGKENNW88IY5MYqTBA8FR0Md8QdGbJD2pKZ+D5IvxjTYDNtJPDxFBiXmka2aJsITdB1BtO1fsgmsQ==", + "version": "4.31.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-4.31.2.tgz", + "integrity": "sha512-kWiTTBCTKEdBGrZKwFvOlGNcAsKGJSBc8xLvSjSppFO88AqGxGNYtF36EuEYG6XZ9vT0xX8RNiHbQUKglbSi1w==", "dev": true }, "@typescript-eslint/typescript-estree": { - "version": "4.31.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-4.31.1.tgz", - "integrity": "sha512-EGHkbsUvjFrvRnusk6yFGqrqMBTue5E5ROnS5puj3laGQPasVUgwhrxfcgkdHNFECHAewpvELE1Gjv0XO3mdWg==", + "version": "4.31.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-4.31.2.tgz", + "integrity": "sha512-ieBq8U9at6PvaC7/Z6oe8D3czeW5d//Fo1xkF/s9394VR0bg/UaMYPdARiWyKX+lLEjY3w/FNZJxitMsiWv+wA==", "dev": true, "requires": { - "@typescript-eslint/types": "4.31.1", - "@typescript-eslint/visitor-keys": "4.31.1", + "@typescript-eslint/types": "4.31.2", + "@typescript-eslint/visitor-keys": "4.31.2", "debug": "^4.3.1", "globby": "^11.0.3", "is-glob": "^4.0.1", @@ -2984,12 +2984,12 @@ } }, "@typescript-eslint/visitor-keys": { - "version": "4.31.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-4.31.1.tgz", - "integrity": "sha512-PCncP8hEqKw6SOJY+3St4LVtoZpPPn+Zlpm7KW5xnviMhdqcsBty4Lsg4J/VECpJjw1CkROaZhH4B8M1OfnXTQ==", + "version": "4.31.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-4.31.2.tgz", + "integrity": "sha512-PrBId7EQq2Nibns7dd/ch6S6/M4/iwLM9McbgeEbCXfxdwRUNxJ4UNreJ6Gh3fI2GNKNrWnQxKL7oCPmngKBug==", "dev": true, "requires": { - "@typescript-eslint/types": "4.31.1", + "@typescript-eslint/types": "4.31.2", "eslint-visitor-keys": "^2.0.0" } }, diff --git a/src/client.ts b/src/client.ts index a41a754..f06c810 100644 --- a/src/client.ts +++ b/src/client.ts @@ -1,4 +1,9 @@ +'use strict'; + import https, { RequestOptions } from 'https'; +import { promises as fs } from 'fs'; +import crypto from 'crypto'; +import path from 'path'; import { URL } from 'url'; /** @@ -82,6 +87,28 @@ interface GoogleIDTokenResponse { token: string; } +/** + * CreateCredentialsFileParameters are the parameters to generate a Google Cloud + * credentials file for use with gcloud and other SDKs. + * + * @param providerID Full path (including project, location, etc) to the Google + * Cloud Workload Identity Provider. + * @param serviceAccount Email address or unique identifier of the service + * account to impersonate + * @param requestToken Local environment token to use as authentication to + * acquire the real OIDC token. + * @param requestURL URL endpoint to use to request the token. + * @param outputDir Path to a directory on disk where the file should be + * written. The function will determine the file name and write to it securely. + */ +interface CreateCredentialsFileParameters { + providerID: string; + serviceAccount: string; + requestToken: string; + requestURL: string; + outputDir: string; +} + export class Client { /** * request is a high-level helper that returns a promise from the executed @@ -255,4 +282,47 @@ export class Client { throw new Error(`failed to generate Google Cloud ID token for ${serviceAccount}: ${err}`); } } + + /** + * createCredentialsFile creates a Google Cloud credentials file that can be + * set as GOOGLE_APPLICATION_CREDENTIALS for gcloud and client libraries. + */ + static async createCredentialsFile({ + providerID, + serviceAccount, + requestToken, + requestURL, + outputDir, + }: CreateCredentialsFileParameters): Promise { + const data = { + type: 'external_account', + audience: `//iam.googleapis.com/${providerID}`, + subject_token_type: 'urn:ietf:params:oauth:token-type:jwt', + token_url: 'https://sts.googleapis.com/v1/token', + service_account_impersonation_url: `https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/${serviceAccount}:generateAccessToken`, + credential_source: { + url: requestURL, + headers: { + Authorization: `Bearer ${requestToken}`, + }, + format: { + type: 'json', + subject_token_field_name: 'value', + }, + }, + }; + + // Generate a random filename to store the credential. 12 bytes is 24 + // characters in hex. It's not the ideal entropy, but we have to be under + // the 255 character limit for Windows filenames (which includes their + // entire leading path). + const uniqueName = crypto.randomBytes(12).toString('hex'); + const pth = path.join(outputDir, uniqueName); + + // Write the file as 0640 so the owner has RW, group as R, and the file is + // otherwise unreadable. Also write with EXCL to prevent a symlink attack. + await fs.writeFile(pth, JSON.stringify(data), { mode: 0o640, flag: 'wx' }); + + return pth; + } } diff --git a/src/main.ts b/src/main.ts index 1f19780..9fd09f3 100644 --- a/src/main.ts +++ b/src/main.ts @@ -2,6 +2,7 @@ import * as core from '@actions/core'; import { Client } from './client'; +import { URL } from 'url'; /** * Converts a multi-line or comma-separated collection of strings into an array @@ -35,26 +36,79 @@ async function run(): Promise { }); const serviceAccount = core.getInput('service_account', { required: true }); const audience = core.getInput('audience'); - const tokenFormat = core.getInput('token_format', { required: true }); + const createCredentialsFile = core.getBooleanInput('create_credentials_file'); + const activateCredentialsFile = core.getBooleanInput('activate_credentials_file'); + const tokenFormat = core.getInput('token_format'); const delegates = explodeStrings(core.getInput('delegates')); - const accessTokenLifetime = core.getInput('access_token_lifetime'); - const accessTokenScopes = explodeStrings(core.getInput('access_token_scopes')); - const idTokenAudience = core.getInput('id_token_audience'); - const idTokenIncludeEmail = core.getBooleanInput('id_token_include_email'); - // Get the GitHub OIDC token. - const githubOIDCToken = await core.getIDToken(audience); + // Always write the credentials file first, before trying to generate + // tokens. This will ensure the file is written even if token generation + // fails, which means continue-on-error actions will still have the file + // available. + if (createCredentialsFile) { + const runnerTempDir = process.env.RUNNER_TEMP!; - // Exchange the GitHub OIDC token for a Google Federated Token. - const googleFederatedToken = await Client.googleFederatedToken({ - providerID: workloadIdentityProvider, - token: githubOIDCToken, - }); - core.setSecret(googleFederatedToken); + // Extract the request token and request URL from the environment. These + // are only set when an id-token is requested and the submitter has + // collaborator permissions. + const requestToken = process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN; + if (!requestToken) { + throw new Error('$ACTIONS_ID_TOKEN_REQUEST_TOKEN is not set'); + } + const requestURLRaw = process.env.ACTIONS_ID_TOKEN_REQUEST_URL; + if (!requestURLRaw) { + throw new Error('$ACTIONS_ID_TOKEN_REQUEST_URL is not set'); + } + const requestURL = new URL(requestURLRaw); + + // Append the audience value to the request. + const params = requestURL.searchParams; + params.set('audience', audience); + requestURL.search = params.toString(); + + // Create the credentials file. + const outputPath = await Client.createCredentialsFile({ + providerID: workloadIdentityProvider, + serviceAccount: serviceAccount, + requestToken: requestToken, + requestURL: requestURL.toString(), + outputDir: runnerTempDir, + }); + core.setOutput('credentials_file_path', outputPath); + + // Also set the magic environment variable for gcloud and SDKs if + // requested. + if (activateCredentialsFile) { + core.exportVariable('GOOGLE_APPLICATION_CREDENTIALS', outputPath); + } + } + + // getFederatedToken is a closure that gets the federated token. + const getFederatedToken = async (): Promise => { + // Get the GitHub OIDC token. + const githubOIDCToken = await core.getIDToken(audience); + + // Exchange the GitHub OIDC token for a Google Federated Token. + const googleFederatedToken = await Client.googleFederatedToken({ + providerID: workloadIdentityProvider, + token: githubOIDCToken, + }); + core.setSecret(googleFederatedToken); + return googleFederatedToken; + }; switch (tokenFormat) { + case '': { + break; + } + case null: { + break; + } case 'access_token': { - // Exchange the Google Federated Token for an access token. + const accessTokenLifetime = core.getInput('access_token_lifetime'); + const accessTokenScopes = explodeStrings(core.getInput('access_token_scopes')); + + const googleFederatedToken = await getFederatedToken(); const { accessToken, expiration } = await Client.googleAccessToken({ token: googleFederatedToken, serviceAccount: serviceAccount, @@ -68,7 +122,10 @@ async function run(): Promise { break; } case 'id_token': { - // Exchange the Google Federated Token for an id token. + const idTokenAudience = core.getInput('id_token_audience', { required: true }); + const idTokenIncludeEmail = core.getBooleanInput('id_token_include_email'); + + const googleFederatedToken = await getFederatedToken(); const { token } = await Client.googleIDToken({ token: googleFederatedToken, serviceAccount: serviceAccount,