mirror of
https://github.com/aws-actions/configure-aws-credentials.git
synced 2026-09-01 05:45:06 +09:00
feat!: Initial v2 commit
* Implemented editorconfig * Switched to ESM * Switched to a TypeScript workflow * Switched to projen for project management * Major code refactor * TODO: need tests
This commit is contained in:
@@ -0,0 +1,137 @@
|
||||
import assert from 'assert';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import * as core from '@actions/core';
|
||||
import { AssumeRoleCommandInput, AssumeRoleWithWebIdentityCommand } from '@aws-sdk/client-sts';
|
||||
import { errorMessage, getStsClient, isDefined } from './helpers.js';
|
||||
|
||||
const SANITIZATION_CHARACTER = '_';
|
||||
const MAX_TAG_VALUE_LENGTH = 256;
|
||||
|
||||
function sanitizeGithubActor(actor: string) {
|
||||
// In some circumstances the actor may contain square brackets. For example, if they're a bot ('[bot]')
|
||||
// Square brackets are not allowed in AWS session tags
|
||||
return actor.replace(/\[|\]/g, SANITIZATION_CHARACTER);
|
||||
}
|
||||
|
||||
function sanitizeGithubWorkflowName(name: string) {
|
||||
// Workflow names can be almost any valid UTF-8 string, but tags are more restrictive.
|
||||
// This replaces anything not conforming to the tag restrictions by inverting the regular expression.
|
||||
// See the AWS documentation for constraint specifics https://docs.aws.amazon.com/STS/latest/APIReference/API_Tag.html.
|
||||
const nameWithoutSpecialCharacters = name.replace(/[^\p{L}\p{Z}\p{N}_:/=+.-@-]/gu, SANITIZATION_CHARACTER);
|
||||
const nameTruncated = nameWithoutSpecialCharacters.slice(0, MAX_TAG_VALUE_LENGTH);
|
||||
return nameTruncated;
|
||||
}
|
||||
|
||||
export interface assumeRoleParams {
|
||||
region: string;
|
||||
roleToAssume: string;
|
||||
roleDurationSeconds: number;
|
||||
roleSessionName: string;
|
||||
roleSkipSessionTagging?: boolean;
|
||||
sourceAccountId?: string;
|
||||
roleExternalId?: string;
|
||||
webIdentityTokenFile?: string;
|
||||
webIdentityToken?: string;
|
||||
}
|
||||
|
||||
export async function assumeRole(params: assumeRoleParams) {
|
||||
// Assume a role to get short-lived credentials using longer-lived credentials.
|
||||
const {
|
||||
sourceAccountId,
|
||||
roleToAssume,
|
||||
roleExternalId,
|
||||
roleDurationSeconds,
|
||||
roleSessionName,
|
||||
region,
|
||||
roleSkipSessionTagging,
|
||||
webIdentityTokenFile,
|
||||
webIdentityToken,
|
||||
} = { ...params };
|
||||
|
||||
const { GITHUB_REPOSITORY, GITHUB_WORKFLOW, GITHUB_ACTION, GITHUB_ACTOR, GITHUB_SHA, GITHUB_WORKSPACE } = process.env;
|
||||
if (!GITHUB_REPOSITORY || !GITHUB_WORKFLOW || !GITHUB_ACTION || !GITHUB_ACTOR || !GITHUB_SHA || !GITHUB_WORKSPACE) {
|
||||
throw new Error('Missing required environment variables. Are you running in GitHub Actions?');
|
||||
}
|
||||
|
||||
let RoleArn = roleToAssume;
|
||||
if (!RoleArn.startsWith('arn:aws')) {
|
||||
// Supports only 'aws' partition. Customers in other partitions ('aws-cn') will need to provide full ARN
|
||||
assert(
|
||||
isDefined(sourceAccountId),
|
||||
'Source Account ID is needed if the Role Name is provided and not the Role Arn.'
|
||||
);
|
||||
RoleArn = `arn:aws:iam::${sourceAccountId}:role/${RoleArn}`;
|
||||
}
|
||||
|
||||
const tagArray = [
|
||||
{ Key: 'GitHub', Value: 'Actions' },
|
||||
{ Key: 'Repository', Value: GITHUB_REPOSITORY },
|
||||
{ Key: 'Workflow', Value: sanitizeGithubWorkflowName(GITHUB_WORKFLOW) },
|
||||
{ Key: 'Action', Value: GITHUB_ACTION },
|
||||
{ Key: 'Actor', Value: sanitizeGithubActor(GITHUB_ACTOR) },
|
||||
{ Key: 'Commit', Value: GITHUB_SHA },
|
||||
];
|
||||
|
||||
if (process.env.GITHUB_REF) {
|
||||
tagArray.push({ Key: 'Branch', Value: process.env.GITHUB_REF });
|
||||
}
|
||||
|
||||
const Tags = roleSkipSessionTagging ? undefined : tagArray;
|
||||
if (!Tags) {
|
||||
core.debug('Role session tagging has been skipped.');
|
||||
} else {
|
||||
core.debug(Tags.length + ' role session tags are being used.');
|
||||
}
|
||||
|
||||
const commonAssumeRoleParams: AssumeRoleCommandInput = {
|
||||
RoleArn,
|
||||
RoleSessionName: roleSessionName,
|
||||
DurationSeconds: roleDurationSeconds,
|
||||
Tags,
|
||||
ExternalId: roleExternalId,
|
||||
};
|
||||
const keys = Object.keys(commonAssumeRoleParams) as Array<keyof typeof commonAssumeRoleParams>;
|
||||
keys.forEach((k) => commonAssumeRoleParams[k] === undefined && delete commonAssumeRoleParams[k]);
|
||||
|
||||
let assumeRoleCommand: AssumeRoleWithWebIdentityCommand;
|
||||
switch (true) {
|
||||
case !!webIdentityToken: {
|
||||
delete commonAssumeRoleParams.Tags;
|
||||
assumeRoleCommand = new AssumeRoleWithWebIdentityCommand({
|
||||
...commonAssumeRoleParams,
|
||||
WebIdentityToken: webIdentityToken,
|
||||
});
|
||||
break;
|
||||
}
|
||||
case !!webIdentityTokenFile: {
|
||||
core.debug(
|
||||
'webIdentityTokenFile provided. Will call sts:AssumeRoleWithWebIdentity and take session tags from token contents.'
|
||||
);
|
||||
|
||||
const webIdentityTokenFilePath = path.isAbsolute(webIdentityTokenFile!)
|
||||
? webIdentityTokenFile!
|
||||
: path.join(GITHUB_WORKSPACE, webIdentityTokenFile!);
|
||||
if (!fs.existsSync(webIdentityTokenFilePath)) {
|
||||
throw new Error(`Web identity token file does not exist: ${webIdentityTokenFilePath}`);
|
||||
}
|
||||
|
||||
try {
|
||||
const widt = await fs.promises.readFile(webIdentityTokenFilePath, 'utf8');
|
||||
delete commonAssumeRoleParams.Tags;
|
||||
assumeRoleCommand = new AssumeRoleWithWebIdentityCommand({
|
||||
...commonAssumeRoleParams,
|
||||
WebIdentityToken: widt,
|
||||
});
|
||||
} catch (error) {
|
||||
throw new Error(`Web identity token file could not be read: ${errorMessage(error)}`);
|
||||
}
|
||||
break;
|
||||
}
|
||||
default:
|
||||
throw new Error('No web identity token or web identity token file provided.');
|
||||
}
|
||||
|
||||
const sts = getStsClient(region);
|
||||
return sts.send(assumeRoleCommand);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import * as url from 'node:url';
|
||||
import * as core from '@actions/core';
|
||||
import { errorMessage } from '../helpers.js';
|
||||
|
||||
/**
|
||||
* When the GitHub Actions job is done, clean up any environment variables that
|
||||
* may have been set by the configure-aws-credentials steps in the job.
|
||||
*
|
||||
* Environment variables are not intended to be shared across different jobs in
|
||||
* the same GitHub Actions workflow: GitHub Actions documentation states that
|
||||
* each job runs in a fresh instance. However, doing our own cleanup will
|
||||
* give us additional assurance that these environment variables are not shared
|
||||
* with any other jobs.
|
||||
*/
|
||||
|
||||
export async function cleanup() {
|
||||
try {
|
||||
// The GitHub Actions toolkit does not have an option to completely unset
|
||||
// environment variables, so we overwrite the current value with an empty
|
||||
// string. The AWS CLI and AWS SDKs will behave correctly: they treat an
|
||||
// empty string value as if the environment variable does not exist.
|
||||
core.exportVariable('AWS_ACCESS_KEY_ID', '');
|
||||
core.exportVariable('AWS_SECRET_ACCESS_KEY', '');
|
||||
core.exportVariable('AWS_SESSION_TOKEN', '');
|
||||
core.exportVariable('AWS_DEFAULT_REGION', '');
|
||||
core.exportVariable('AWS_REGION', '');
|
||||
} catch (error) {
|
||||
core.setFailed(errorMessage(error));
|
||||
}
|
||||
}
|
||||
|
||||
const modulePath = url.fileURLToPath(import.meta.url);
|
||||
if (process.argv[1] === modulePath) {
|
||||
await cleanup();
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { STSClient } from '@aws-sdk/client-sts';
|
||||
|
||||
const MAX_TAG_VALUE_LENGTH = 256;
|
||||
const SANITIZATION_CHARACTER = '_';
|
||||
|
||||
let stsclient: STSClient | undefined;
|
||||
|
||||
export function getStsClient(region: string, agent?: string) {
|
||||
if (!stsclient) {
|
||||
stsclient = new STSClient({
|
||||
region,
|
||||
customUserAgent: agent,
|
||||
});
|
||||
}
|
||||
return stsclient;
|
||||
}
|
||||
|
||||
export function sanitizeGithubActor(actor: string) {
|
||||
// In some circumstances the actor may contain square brackets. For example, if they're a bot ('[bot]')
|
||||
// Square brackets are not allowed in AWS session tags
|
||||
return actor.replace(/\[|\]/g, SANITIZATION_CHARACTER);
|
||||
}
|
||||
|
||||
export function sanitizeGithubWorkflowName(name: string) {
|
||||
// Workflow names can be almost any valid UTF-8 string, but tags are more restrictive.
|
||||
// This replaces anything not conforming to the tag restrictions by inverting the regular expression.
|
||||
// See the AWS documentation for constraint specifics https://docs.aws.amazon.com/STS/latest/APIReference/API_Tag.html.
|
||||
const nameWithoutSpecialCharacters = name.replace(/[^\p{L}\p{Z}\p{N}_:/=+.-@-]/gu, SANITIZATION_CHARACTER);
|
||||
const nameTruncated = nameWithoutSpecialCharacters.slice(0, MAX_TAG_VALUE_LENGTH);
|
||||
return nameTruncated;
|
||||
}
|
||||
|
||||
export function errorMessage(error: unknown) {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
export function isDefined<T>(i: T | undefined | null): i is T {
|
||||
return i !== undefined && i !== null;
|
||||
}
|
||||
|
||||
function sleep(ms: number) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
// retryAndBackoff retries with exponential backoff the promise if the error isRetryable upto maxRetries time.
|
||||
export async function retryAndBackoff<T>(
|
||||
fn: () => Promise<T>,
|
||||
isRetryable: boolean,
|
||||
retries = 0,
|
||||
maxRetries = 12,
|
||||
base = 50
|
||||
): Promise<T> {
|
||||
try {
|
||||
return await fn();
|
||||
} catch (err) {
|
||||
if (!isRetryable) {
|
||||
throw err;
|
||||
}
|
||||
// It's retryable, so sleep and retry.
|
||||
await sleep(Math.random() * (Math.pow(2, retries) * base));
|
||||
retries += 1;
|
||||
if (retries === maxRetries) {
|
||||
throw err;
|
||||
}
|
||||
return await retryAndBackoff(fn, isRetryable, retries, maxRetries, base);
|
||||
}
|
||||
}
|
||||
+210
@@ -0,0 +1,210 @@
|
||||
import * as url from 'node:url';
|
||||
import * as core from '@actions/core';
|
||||
import { Credentials, GetCallerIdentityCommand, STSClient } from '@aws-sdk/client-sts';
|
||||
import { assumeRole } from './assumeRole.js';
|
||||
import { errorMessage, getStsClient, retryAndBackoff } from './helpers.js';
|
||||
|
||||
// Use 1hr as role duration when using session token or OIDC
|
||||
// Otherwise, use the max duration of GitHub action (6hr)
|
||||
const MAX_ACTION_RUNTIME = 6 * 3600;
|
||||
const SESSION_ROLE_DURATION = 3600;
|
||||
const DEFAULT_ROLE_DURATION_FOR_OIDC_ROLES = 3600;
|
||||
const USER_AGENT = 'configure-aws-credentials-for-github-actions';
|
||||
const ROLE_SESSION_NAME = 'GitHubActions';
|
||||
const REGION_REGEX = /^[a-z0-9-]+$/g;
|
||||
|
||||
function exportCredentials(creds?: Partial<Credentials>) {
|
||||
// Configure the AWS CLI and AWS SDKs using environment variables and set them as secrets.
|
||||
// Setting the credentials as secrets masks them in Github Actions logs
|
||||
|
||||
// AWS_ACCESS_KEY_ID:
|
||||
// Specifies an AWS access key associated with an IAM user or role
|
||||
if (creds?.AccessKeyId) {
|
||||
core.setSecret(creds.AccessKeyId);
|
||||
core.exportVariable('AWS_ACCESS_KEY_ID', creds.AccessKeyId);
|
||||
}
|
||||
|
||||
// AWS_SECRET_ACCESS_KEY:
|
||||
// Specifies the secret key associated with the access key. This is essentially the "password" for the access key.
|
||||
if (creds?.SecretAccessKey) {
|
||||
core.setSecret(creds.SecretAccessKey);
|
||||
core.exportVariable('AWS_SECRET_ACCESS_KEY', creds.SecretAccessKey);
|
||||
}
|
||||
|
||||
// AWS_SESSION_TOKEN:
|
||||
// Specifies the session token value that is required if you are using temporary security credentials.
|
||||
if (creds?.SessionToken) {
|
||||
core.setSecret(creds.SessionToken);
|
||||
core.exportVariable('AWS_SESSION_TOKEN', creds.SessionToken);
|
||||
} else if (process.env.AWS_SESSION_TOKEN) {
|
||||
// clear session token from previous credentials action
|
||||
core.exportVariable('AWS_SESSION_TOKEN', '');
|
||||
}
|
||||
}
|
||||
|
||||
function exportRegion(region: string) {
|
||||
// AWS_DEFAULT_REGION and AWS_REGION:
|
||||
// Specifies the AWS Region to send requests to
|
||||
core.exportVariable('AWS_DEFAULT_REGION', region);
|
||||
core.exportVariable('AWS_REGION', region);
|
||||
}
|
||||
|
||||
async function exportAccountId(region: string, maskAccountId?: boolean) {
|
||||
// Get the AWS account ID
|
||||
const client = getStsClient(region, USER_AGENT);
|
||||
const identity = (await client.send(new GetCallerIdentityCommand({}))).Account;
|
||||
if (!identity) {
|
||||
throw new Error('Could not get Account ID from STS. Did you set credentials?');
|
||||
}
|
||||
if (maskAccountId) {
|
||||
core.setSecret(identity);
|
||||
} else {
|
||||
core.setOutput('aws-account-id', identity);
|
||||
}
|
||||
return identity;
|
||||
}
|
||||
|
||||
function loadCredentials() {
|
||||
// Previously, this function forced the SDK to re-resolve credentials with the default provider chain.
|
||||
//
|
||||
// This action typically sets credentials in the environment via environment variables. The SDK never refreshed those
|
||||
// env-var-based credentials after initial load. In case there were already env-var creds set in the actions
|
||||
// environment when this action loaded, this action needed to refresh the SDK creds after overwriting those
|
||||
// environment variables.
|
||||
//
|
||||
// However, in V3 of the JavaScript SDK, there is no longer a global configuration object: all configuration,
|
||||
// including credentials, are instantiated per client and not merged back into global state.
|
||||
|
||||
const client = new STSClient({});
|
||||
return client.config.credentials();
|
||||
}
|
||||
|
||||
async function validateCredentials(expectedAccessKeyId?: string) {
|
||||
let credentials;
|
||||
try {
|
||||
credentials = await loadCredentials();
|
||||
if (!credentials.accessKeyId) {
|
||||
throw new Error('Access key ID empty after loading credentials');
|
||||
}
|
||||
} catch (error) {
|
||||
throw new Error(`Credentials could not be loaded, please check your action inputs: ${errorMessage(error)}`);
|
||||
}
|
||||
|
||||
const actualAccessKeyId = credentials.accessKeyId;
|
||||
|
||||
if (expectedAccessKeyId && expectedAccessKeyId !== actualAccessKeyId) {
|
||||
throw new Error(
|
||||
'Unexpected failure: Credentials loaded by the SDK do not match the access key ID configured by the action'
|
||||
);
|
||||
}
|
||||
}
|
||||
export async function run() {
|
||||
try {
|
||||
// Get inputs
|
||||
const AccessKeyId = core.getInput('aws-access-key-id', { required: false });
|
||||
const audience = core.getInput('audience', { required: false });
|
||||
const SecretAccessKey = core.getInput('aws-secret-access-key', { required: false });
|
||||
const region = core.getInput('aws-region', { required: true });
|
||||
const SessionToken = core.getInput('aws-session-token', { required: false });
|
||||
const maskAccountId =
|
||||
(core.getInput('mask-aws-account-id', { required: false }) || 'true').toLowerCase() === 'true';
|
||||
const roleToAssume = core.getInput('role-to-assume', { required: false });
|
||||
const roleExternalId = core.getInput('role-external-id', { required: false });
|
||||
const roleSessionName = core.getInput('role-session-name', { required: false }) || ROLE_SESSION_NAME;
|
||||
const roleSkipSessionTaggingInput = core.getInput('role-skip-session-tagging', { required: false }) || 'false';
|
||||
const roleSkipSessionTagging = roleSkipSessionTaggingInput.toLowerCase() === 'true';
|
||||
const webIdentityTokenFile = core.getInput('web-identity-token-file', { required: false });
|
||||
|
||||
// This wraps the logic for deciding if we should rely on the GH OIDC provider since we may need to reference
|
||||
// the decision in a few differennt places. Consolidating it here makes the logic clearer elsewhere.
|
||||
const useGitHubOIDCProvider = () => {
|
||||
// The assumption here is that self-hosted runners won't be populating the `ACTIONS_ID_TOKEN_REQUEST_TOKEN`
|
||||
// environment variable and they won't be providing a web idenity token file or access key either.
|
||||
// V2 of the action might relax this a bit and create an explicit precedence for these so that customers
|
||||
// can provide as much info as they want and we will follow the established credential loading precedence.
|
||||
return !!(roleToAssume && process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN && !AccessKeyId && !webIdentityTokenFile);
|
||||
};
|
||||
const roleDurationSeconds =
|
||||
parseInt(core.getInput('role-duration-seconds', { required: false })) ||
|
||||
(SessionToken && SESSION_ROLE_DURATION) ||
|
||||
(useGitHubOIDCProvider() && DEFAULT_ROLE_DURATION_FOR_OIDC_ROLES) ||
|
||||
MAX_ACTION_RUNTIME;
|
||||
|
||||
if (!region.match(REGION_REGEX)) {
|
||||
throw new Error(`Region is not valid: ${region}`);
|
||||
}
|
||||
|
||||
exportRegion(region);
|
||||
|
||||
// Always export the source credentials and account ID.
|
||||
// The STS client for calling AssumeRole pulls creds from the environment.
|
||||
// Plus, in the assume role case, if the AssumeRole call fails, we want
|
||||
// the source credentials and account ID to already be masked as secrets
|
||||
// in any error messages.
|
||||
if (AccessKeyId) {
|
||||
if (!SecretAccessKey) {
|
||||
throw new Error("'aws-secret-access-key' must be provided if 'aws-access-key-id' is provided");
|
||||
}
|
||||
|
||||
exportCredentials({ AccessKeyId, SecretAccessKey, SessionToken });
|
||||
}
|
||||
|
||||
// Attempt to load credentials from the GitHub OIDC provider.
|
||||
// If a user provides an IAM Role Arn and DOESN'T provide an Access Key Id
|
||||
// The only way to assume the role is via GitHub's OIDC provider.
|
||||
let sourceAccountId: string;
|
||||
let webIdentityToken: string;
|
||||
if (useGitHubOIDCProvider()) {
|
||||
webIdentityToken = await core.getIDToken(audience);
|
||||
// We don't validate the credentials here because we don't have them yet when using OIDC.
|
||||
} else {
|
||||
// Regardless of whether any source credentials were provided as inputs,
|
||||
// validate that the SDK can actually pick up credentials. This validates
|
||||
// cases where this action is on a self-hosted runner that doesn't have credentials
|
||||
// configured correctly, and cases where the user intended to provide input
|
||||
// credentials but the secrets inputs resolved to empty strings.
|
||||
await validateCredentials(AccessKeyId);
|
||||
|
||||
sourceAccountId = await exportAccountId(region, maskAccountId);
|
||||
}
|
||||
|
||||
// Get role credentials if configured to do so
|
||||
if (roleToAssume) {
|
||||
const roleCredentials = await retryAndBackoff(async () => {
|
||||
return assumeRole({
|
||||
sourceAccountId,
|
||||
region,
|
||||
roleToAssume,
|
||||
roleExternalId,
|
||||
roleDurationSeconds,
|
||||
roleSessionName,
|
||||
roleSkipSessionTagging,
|
||||
webIdentityTokenFile,
|
||||
webIdentityToken,
|
||||
});
|
||||
}, true);
|
||||
exportCredentials(roleCredentials.Credentials);
|
||||
// We need to validate the credentials in 2 of our use-cases
|
||||
// First: self-hosted runners. If the GITHUB_ACTIONS environment variable
|
||||
// is set to `true` then we are NOT in a self-hosted runner.
|
||||
// Second: Customer provided credentials manually (IAM User keys stored in GH Secrets)
|
||||
if (!process.env.GITHUB_ACTIONS || AccessKeyId) {
|
||||
await validateCredentials(roleCredentials.Credentials?.AccessKeyId);
|
||||
}
|
||||
await exportAccountId(region, maskAccountId);
|
||||
}
|
||||
} catch (error) {
|
||||
core.setFailed(errorMessage(error));
|
||||
|
||||
const showStackTrace = process.env.SHOW_STACK_TRACE;
|
||||
|
||||
if (showStackTrace === 'true') {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const modulePath = url.fileURLToPath(import.meta.url);
|
||||
if (process.argv[1] === modulePath) {
|
||||
await run();
|
||||
}
|
||||
Reference in New Issue
Block a user