auth/src/main.ts
Mike Verbanic dcb0567d1a
fix: update doc versions (#240)
<!--
Thank you for proposing a pull request! Please note that SOME TESTS WILL
LIKELY FAIL due to how GitHub exposes secrets in Pull Requests from
forks.
Someone from the team will review your Pull Request and respond.

Please describe your change and any implementation details below.
-->
2022-11-09 09:11:19 -05:00

310 lines
11 KiB
TypeScript

'use strict';
import { join as pathjoin } from 'path';
import {
debug as logDebug,
exportVariable,
getBooleanInput,
getIDToken,
getInput,
info as logInfo,
setFailed,
setOutput,
setSecret,
warning as logWarning,
} from '@actions/core';
import {
errorMessage,
exactlyOneOf,
isEmptyDir,
isPinnedToHead,
parseCSV,
parseDuration,
pinnedToHeadWarning,
withRetries,
} from '@google-github-actions/actions-utils';
import { WorkloadIdentityClient } from './client/workload_identity_client';
import { CredentialsJSONClient } from './client/credentials_json_client';
import { AuthClient } from './client/auth_client';
import { buildDomainWideDelegationJWT, 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<void> {
// Warn if pinned to HEAD
if (isPinnedToHead()) {
logWarning(pinnedToHeadWarning('v1'));
}
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(main, {
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() {
// Load configuration.
const projectID = getInput('project_id');
const workloadIdentityProvider = getInput('workload_identity_provider');
const serviceAccount = getInput('service_account');
const audience = 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,
);
}
// Ensure a service_account was provided if using WIF.
if (workloadIdentityProvider && !serviceAccount) {
throw new Error(
'The GitHub Action workflow must specify a "service_account" to ' +
'impersonate when using "workload_identity_provider"! ' +
secretsWarning,
);
}
// Instantiate the correct client based on the provided input parameters.
let client: AuthClient;
if (workloadIdentityProvider) {
logDebug(`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 token = await getIDToken(audience);
client = new WorkloadIdentityClient({
projectID: projectID,
providerID: workloadIdentityProvider,
serviceAccount: serviceAccount,
token: token,
audience: audience,
oidcTokenRequestToken: oidcTokenRequestToken,
oidcTokenRequestURL: oidcTokenRequestURL,
});
} else {
logDebug(`Using credentials JSON`);
client = new CredentialsJSONClient({
projectID: projectID,
credentialsJSON: 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) {
logDebug(`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) {
logWarning(
`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);
logInfo(`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).
exportVariableAndWarn('CLOUDSDK_AUTH_CREDENTIAL_FILE_OVERRIDE', credentialsPath);
// GOOGLE_APPLICATION_CREDENTIALS is used by Application Default
// Credentials in all GCP client libraries.
exportVariableAndWarn('GOOGLE_APPLICATION_CREDENTIALS', credentialsPath);
// GOOGLE_GHA_CREDS_PATH is used by other Google GitHub Actions.
exportVariableAndWarn('GOOGLE_GHA_CREDS_PATH', credentialsPath);
}
}
// Set the project ID environment variables to the computed values.
const computedProjectID = await client.getProjectID();
setOutput('project_id', computedProjectID);
if (exportEnvironmentVariables) {
exportVariableAndWarn('CLOUDSDK_CORE_PROJECT', computedProjectID);
exportVariableAndWarn('CLOUDSDK_PROJECT', computedProjectID);
exportVariableAndWarn('GCLOUD_PROJECT', computedProjectID);
exportVariableAndWarn('GCP_PROJECT', computedProjectID);
exportVariableAndWarn('GOOGLE_CLOUD_PROJECT', computedProjectID);
}
switch (tokenFormat) {
case '': {
break;
}
case null: {
break;
}
case 'access_token': {
logDebug(`Creating access token`);
const accessTokenLifetime = parseDuration(getInput('access_token_lifetime'));
const accessTokenScopes = parseCSV(getInput('access_token_scopes'));
const accessTokenSubject = getInput('access_token_subject');
const serviceAccount = await client.getServiceAccount();
// 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, expiration;
if (accessTokenSubject) {
if (accessTokenLifetime > 3600) {
logInfo(
`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, delegates);
({ accessToken, expiration } = await client.googleOAuthToken(signedJWT));
} else {
const authToken = await client.getAuthToken();
({ accessToken, expiration } = await client.googleAccessToken(authToken, {
serviceAccount,
delegates,
scopes: accessTokenScopes,
lifetime: accessTokenLifetime,
}));
}
setSecret(accessToken);
setOutput('access_token', accessToken);
setOutput('access_token_expiration', expiration);
break;
}
case 'id_token': {
logDebug(`Creating id token`);
const idTokenAudience = getInput('id_token_audience', { required: true });
const idTokenIncludeEmail = getBooleanInput('id_token_include_email');
const serviceAccount = await client.getServiceAccount();
const authToken = await client.getAuthToken();
const { token } = await client.googleIDToken(authToken, {
serviceAccount,
audience: idTokenAudience,
delegates,
includeEmail: idTokenIncludeEmail,
});
setSecret(token);
setOutput('id_token', token);
break;
}
default: {
throw new Error(`Unknown token format "${tokenFormat}"`);
}
}
}
/**
* exportVariableAndWarn exports the given key as an environment variable set to
* the provided value. If a value already exists, it is overwritten and an
* warning is emitted.
*
* @param key Environment variable key.
* @param value Environment variable value.
*/
function exportVariableAndWarn(key: string, value: string) {
const existing = process.env[key];
if (existing) {
const old = JSON.stringify(existing);
logWarning(`Overwriting existing environment variable ${key} (was: ${old})`);
}
exportVariable(key, value);
}
run();