// Copyright 2023 Google LLC // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. import { join as pathjoin } from 'path'; import { exportVariable, getBooleanInput, getIDToken, getInput, setFailed, setOutput, setSecret, } from '@actions/core'; import { errorMessage, exactlyOneOf, isEmptyDir, isPinnedToHead, parseCSV, parseDuration, pinnedToHeadWarning, withRetries, } from '@google-github-actions/actions-utils'; import { AuthClient, IAMCredentialsClient, ServiceAccountKeyClient, WorkloadIdentityFederationClient, } from './base'; import { Logger } from './logger'; import { buildDomainWideDelegationJWT, computeProjectID, computeServiceAccountEmail, generateCredentialsFilename, } from './utils'; const secretsWarning = `If you are specifying input values via GitHub secrets, ensure the secret ` + `is being injected into the environment. By default, secrets are not ` + `passed to workflows triggered from forks, including Dependabot.`; const oidcWarning = `GitHub Actions did not inject $ACTIONS_ID_TOKEN_REQUEST_TOKEN or ` + `$ACTIONS_ID_TOKEN_REQUEST_URL into this job. This most likely means the ` + `GitHub Actions workflow permissions are incorrect, or this job is being ` + `run from a fork. For more information, please see https://docs.github.com/en/actions/security-guides/automatic-token-authentication#permissions-for-the-github_token`; /** * Executes the main action. */ async function run(): Promise { const logger = new Logger(); // Warn if pinned to HEAD if (isPinnedToHead()) { logger.warning(pinnedToHeadWarning('v2')); } const retries = Number(getInput('retries')); // set to undefined when not provided [avoids Number('') -> 0] const backoff = Number(getInput('backoff')) || undefined; const backoffLimit = Number(getInput('backoff_limit')) || undefined; try { const mainWithRetries = withRetries(async () => main(logger), { retries: retries, backoff: backoff, backoffLimit: backoffLimit, }); await mainWithRetries(); } catch (err) { const msg = errorMessage(err); setFailed(`google-github-actions/auth failed with: ${msg}`); } } /** * Main wraps the main action logic into a function to be used as a parameter to the withRetries function. */ async function main(logger: Logger) { // Load configuration. const projectID = computeProjectID( getInput(`project_id`), getInput(`service_account`), getInput(`credentials_json`), ); const workloadIdentityProvider = getInput(`workload_identity_provider`); const serviceAccount = computeServiceAccountEmail( getInput(`service_account`), getInput('credentials_json'), ); const oidcTokenAudience = getInput(`audience`) || `https://iam.googleapis.com/${workloadIdentityProvider}`; const credentialsJSON = getInput(`credentials_json`); const createCredentialsFile = getBooleanInput(`create_credentials_file`); const exportEnvironmentVariables = getBooleanInput(`export_environment_variables`); const tokenFormat = getInput(`token_format`); const delegates = parseCSV(getInput(`delegates`)); // Ensure exactly one of workload_identity_provider and credentials_json was // provided. if (!exactlyOneOf(workloadIdentityProvider, credentialsJSON)) { throw new Error( 'The GitHub Action workflow must specify exactly one of ' + '"workload_identity_provider" or "credentials_json"! ' + secretsWarning, ); } // Instantiate the correct client based on the provided input parameters. let client: AuthClient; if (workloadIdentityProvider) { logger.debug(`Using workload identity provider "${workloadIdentityProvider}"`); // If we're going to do the OIDC dance, we need to make sure these values // are set. If they aren't, core.getIDToken() will fail and so will // generating the credentials file. const oidcTokenRequestToken = process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN; const oidcTokenRequestURL = process.env.ACTIONS_ID_TOKEN_REQUEST_URL; if (!oidcTokenRequestToken || !oidcTokenRequestURL) { throw new Error(oidcWarning); } const oidcToken = await getIDToken(oidcTokenAudience); client = new WorkloadIdentityFederationClient(logger, { githubOIDCToken: oidcToken, githubOIDCTokenRequestURL: oidcTokenRequestURL, githubOIDCTokenRequestToken: oidcTokenRequestToken, githubOIDCTokenAudience: oidcTokenAudience, workloadIdentityProviderName: workloadIdentityProvider, serviceAccount: serviceAccount, }); } else { logger.debug(`Using credentials JSON`); client = new ServiceAccountKeyClient(logger, { serviceAccountKey: credentialsJSON, }); } // 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) { logger.debug(`Creating credentials file`); // Note: We explicitly and intentionally export to GITHUB_WORKSPACE // instead of RUNNER_TEMP, because RUNNER_TEMP is not shared with // Docker-based actions on the filesystem. Exporting to GITHUB_WORKSPACE // ensures that the exported credentials are automatically available to // Docker-based actions without user modification. // // This has the unintended side-effect of leaking credentials over time, // because GITHUB_WORKSPACE is not automatically cleaned up on self-hosted // runners. To mitigate this issue, this action defines a post step to // remove any created credentials. const githubWorkspace = process.env.GITHUB_WORKSPACE; if (!githubWorkspace) { throw new Error('$GITHUB_WORKSPACE is not set'); } // There have been a number of issues where users have not used the // "actions/checkout" step before our action. Our action relies on the // creation of that directory; worse, if a user puts "actions/checkout" // after our action, it will delete the exported credential. This // following code does a small check to see if there are any files in the // directory. It emits a warning if there are no files, since there may be // legitimate use cases for authenticating without checking out the // repository. const githubWorkspaceIsEmpty = await isEmptyDir(githubWorkspace); if (githubWorkspaceIsEmpty) { logger.warning( `The "create_credentials_file" option is true, but the current ` + `GitHub workspace is empty. Did you forget to use ` + `"actions/checkout" before this step? If you do not intend to ` + `share authentication with future steps in this job, set ` + `"create_credentials_file" to false.`, ); } // Create credentials file. const outputFile = generateCredentialsFilename(); const outputPath = pathjoin(githubWorkspace, outputFile); const credentialsPath = await client.createCredentialsFile(outputPath); logger.info(`Created credentials file at "${credentialsPath}"`); // Output to be available to future steps. setOutput('credentials_file_path', credentialsPath); if (exportEnvironmentVariables) { // CLOUDSDK_AUTH_CREDENTIAL_FILE_OVERRIDE is picked up by gcloud to // use a specific credential file (subject to change and equivalent to // auth/credential_file_override). exportVariable('CLOUDSDK_AUTH_CREDENTIAL_FILE_OVERRIDE', credentialsPath); // GOOGLE_APPLICATION_CREDENTIALS is used by Application Default // Credentials in all GCP client libraries. exportVariable('GOOGLE_APPLICATION_CREDENTIALS', credentialsPath); // GOOGLE_GHA_CREDS_PATH is used by other Google GitHub Actions. exportVariable('GOOGLE_GHA_CREDS_PATH', credentialsPath); } } // Set the project ID environment variables to the computed values. if (!projectID) { logger.warning( `Unable to compute project ID from inputs, skipping export. Please ` + `specify the "project_id" input directly.`, ); } else { setOutput('project_id', projectID); if (exportEnvironmentVariables) { exportVariable('CLOUDSDK_CORE_PROJECT', projectID); exportVariable('CLOUDSDK_PROJECT', projectID); exportVariable('GCLOUD_PROJECT', projectID); exportVariable('GCP_PROJECT', projectID); exportVariable('GOOGLE_CLOUD_PROJECT', projectID); } } // Attempt to generate a token. This will ensure the action correctly errors // if the credentials are misconfigured. This is also required so the value // can be set as an output for future authentication calls. const authToken = await client.getToken(); logger.debug(`Successfully generated auth token`); setSecret(authToken); setOutput('auth_token', authToken); // Create the credential client, we might not use it, but it's basically free. const iamCredentialsClient = new IAMCredentialsClient(logger, { authToken: authToken, }); switch (tokenFormat) { case '': { break; } case null: { break; } case 'access_token': { logger.debug(`Creating access token`); const accessTokenLifetime = parseDuration(getInput('access_token_lifetime')); const accessTokenScopes = parseCSV(getInput('access_token_scopes')); const accessTokenSubject = getInput('access_token_subject'); // Ensure a service_account was provided if using WIF. if (!serviceAccount) { throw new Error( 'The GitHub Action workflow must specify a "service_account" to ' + 'use when generating an OAuth 2.0 Access Token. ' + secretsWarning, ); } // If a subject was provided, use the traditional OAuth 2.0 flow to // perform Domain-Wide Delegation. Otherwise, use the modern IAM // Credentials endpoints. let accessToken; if (accessTokenSubject) { if (accessTokenLifetime > 3600) { logger.info( `An access token subject was specified, triggering Domain-Wide ` + `Delegation flow. This flow does not support specifying an ` + `access token lifetime of greater than 1 hour.`, ); } const unsignedJWT = buildDomainWideDelegationJWT( serviceAccount, accessTokenSubject, accessTokenScopes, accessTokenLifetime, ); const signedJWT = await client.signJWT(unsignedJWT); accessToken = await iamCredentialsClient.generateDomainWideDelegationAccessToken(signedJWT); } else { accessToken = await iamCredentialsClient.generateAccessToken({ serviceAccount, delegates, scopes: accessTokenScopes, lifetime: accessTokenLifetime, }); } setSecret(accessToken); setOutput('access_token', accessToken); break; } case 'id_token': { logger.debug(`Creating id token`); const idTokenAudience = getInput('id_token_audience', { required: true }); const idTokenIncludeEmail = getBooleanInput('id_token_include_email'); // Ensure a service_account was provided if using WIF. if (!serviceAccount) { throw new Error( 'The GitHub Action workflow must specify a "service_account" to ' + 'use when generating an OAuth 2.0 Access Token. ' + secretsWarning, ); } const idToken = await iamCredentialsClient.generateIDToken({ serviceAccount, audience: idTokenAudience, delegates, includeEmail: idTokenIncludeEmail, }); setSecret(idToken); setOutput('id_token', idToken); break; } default: { throw new Error(`Unknown token format "${tokenFormat}"`); } } } run();