refactor, add new major version features and proxy

This commit is contained in:
peterwoodworth
2023-03-10 14:35:59 -08:00
parent 0037a686cc
commit f9f25e69f5
9 changed files with 333 additions and 212 deletions
+66
View File
@@ -0,0 +1,66 @@
import { STSClient } from '@aws-sdk/client-sts';
import { NodeHttpHandler } from '@aws-sdk/node-http-handler';
import proxy from 'https-proxy-agent';
import { errorMessage } from './helpers';
const USER_AGENT = 'configure-aws-credentials-for-github-actions';
export interface CredentialsClientProps {
region: string;
proxyServer?: string;
}
export class CredentialsClient {
public region: string;
private stsClient?: STSClient;
private readonly requestHandler?: NodeHttpHandler;
constructor(props: CredentialsClientProps) {
this.region = props.region;
if (props.proxyServer) {
const handler = proxy(props.proxyServer);
this.requestHandler = new NodeHttpHandler({
httpAgent: handler,
httpsAgent: handler,
});
}
}
public getStsClient(): STSClient {
if (!this.stsClient) {
this.stsClient = new STSClient({
region: this.region,
customUserAgent: USER_AGENT,
requestHandler: this.requestHandler ? this.requestHandler : undefined,
});
}
return this.stsClient;
}
public async validateCredentials(expectedAccessKeyId?: string) {
let credentials;
try {
credentials = await this.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'
);
}
}
private async loadCredentials() {
const client = new STSClient({
requestHandler: this.requestHandler ? this.requestHandler : undefined,
});
return client.config.credentials();
}
}
+93 -58
View File
@@ -2,14 +2,69 @@ import assert from 'assert';
import fs from 'fs';
import path from 'path';
import * as core from '@actions/core';
import type { AssumeRoleCommandInput, Tag } from '@aws-sdk/client-sts';
import type { AssumeRoleCommandInput, STSClient, Tag } from '@aws-sdk/client-sts';
import { AssumeRoleCommand, AssumeRoleWithWebIdentityCommand } from '@aws-sdk/client-sts';
import { errorMessage, getStsClient, isDefined, sanitizeGithubActor, sanitizeGithubWorkflowName } from './helpers';
import type { CredentialsClient } from './CredentialsClient';
import { errorMessage, isDefined, sanitizeGithubActor, sanitizeGithubWorkflowName } from './helpers';
async function assumeRoleWithOIDC(params: AssumeRoleCommandInput, client: STSClient, webIdentityToken: string) {
delete params.Tags;
core.info('Assuming role with OIDC');
try {
return await client.send(
new AssumeRoleWithWebIdentityCommand({
...params,
WebIdentityToken: webIdentityToken,
})
);
} catch (error) {
throw new Error(`Could not assume role with OIDC: ${errorMessage(error)}`);
}
}
async function assumeRoleWithWebIdentityTokenFile(
params: AssumeRoleCommandInput,
client: STSClient,
webIdentityTokenFile: string,
workspace: string
) {
core.debug(
'webIdentityTokenFile provided. Will call sts:AssumeRoleWithWebIdentity and take session tags from token contents.'
);
const webIdentityTokenFilePath = path.isAbsolute(webIdentityTokenFile)
? webIdentityTokenFile
: path.join(workspace, webIdentityTokenFile);
if (!fs.existsSync(webIdentityTokenFilePath)) {
throw new Error(`Web identity token file does not exist: ${webIdentityTokenFilePath}`);
}
core.info('Assuming role with web identity token file');
try {
const webIdentityToken = fs.readFileSync(webIdentityTokenFilePath, 'utf8');
delete params.Tags;
return await client.send(
new AssumeRoleWithWebIdentityCommand({
...params,
WebIdentityToken: webIdentityToken,
})
);
} catch (error) {
throw new Error(`Could not assume role with web identity token file: ${errorMessage(error)}`);
}
}
async function assumeRoleWithCredentials(params: AssumeRoleCommandInput, client: STSClient) {
core.info('Assuming role with user credentials');
try {
return await client.send(new AssumeRoleCommand({ ...params }));
} catch (error) {
throw new Error(`Could not assume role with user credentials: ${errorMessage(error)}`);
}
}
export interface assumeRoleParams {
region: string;
credentialsClient: CredentialsClient;
roleToAssume: string;
roleDurationSeconds: number;
roleDuration: number;
roleSessionName: string;
roleSkipSessionTagging?: boolean;
sourceAccountId?: string;
@@ -19,34 +74,25 @@ export interface assumeRoleParams {
}
export async function assumeRole(params: assumeRoleParams) {
// Assume a role to get short-lived credentials using longer-lived credentials.
const {
credentialsClient,
sourceAccountId,
roleToAssume,
roleExternalId,
roleDurationSeconds,
roleDuration,
roleSessionName,
region,
roleSkipSessionTagging,
webIdentityTokenFile,
webIdentityToken,
} = { ...params };
// Load GitHub environment variables
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}`;
}
// Load role session tags
const tagArray: Tag[] = [
{ Key: 'GitHub', Value: 'Actions' },
{ Key: 'Repository', Value: GITHUB_REPOSITORY },
@@ -55,68 +101,57 @@ export async function assumeRole(params: assumeRoleParams) {
{ 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) {
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.`);
core.debug(`${tags.length} role session tags are being used.`);
}
const ExternalId = roleExternalId;
// Calculate role ARN from name and account ID (currently only supports `aws` partition)
let roleArn = roleToAssume;
if (!roleArn.startsWith('arn:aws')) {
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}`;
}
// Ready common parameters to assume role
const commonAssumeRoleParams: AssumeRoleCommandInput = {
RoleArn,
RoleArn: roleArn,
RoleSessionName: roleSessionName,
DurationSeconds: roleDurationSeconds,
...(Tags ? { Tags } : {}),
...(ExternalId ? { ExternalId } : {}),
DurationSeconds: roleDuration,
Tags: tags ? tags : undefined,
ExternalId: roleExternalId ? roleExternalId : undefined,
};
const keys = Object.keys(commonAssumeRoleParams) as Array<keyof typeof commonAssumeRoleParams>;
keys.forEach((k) => commonAssumeRoleParams[k] === undefined && delete commonAssumeRoleParams[k]);
const sts = getStsClient(region);
// Instantiate STS client
const stsClient = credentialsClient.getStsClient();
// Assume role using one of three methods
switch (true) {
case !!webIdentityToken: {
delete commonAssumeRoleParams.Tags;
return sts.send(
new AssumeRoleWithWebIdentityCommand({
...commonAssumeRoleParams,
WebIdentityToken: webIdentityToken,
})
);
return assumeRoleWithOIDC(commonAssumeRoleParams, stsClient, webIdentityToken!);
}
case !!webIdentityTokenFile: {
core.debug(
'webIdentityTokenFile provided. Will call sts:AssumeRoleWithWebIdentity and take session tags from token contents.'
return assumeRoleWithWebIdentityTokenFile(
commonAssumeRoleParams,
stsClient,
webIdentityTokenFile!,
GITHUB_WORKSPACE
);
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 = fs.readFileSync(webIdentityTokenFilePath, 'utf8');
delete commonAssumeRoleParams.Tags;
return await sts.send(
new AssumeRoleWithWebIdentityCommand({
...commonAssumeRoleParams,
WebIdentityToken: widt,
})
);
} catch (error) {
throw new Error(`Web identity token file could not be read: ${errorMessage(error)}`);
}
}
default: {
return sts.send(new AssumeRoleCommand({ ...commonAssumeRoleParams }));
return assumeRoleWithCredentials(commonAssumeRoleParams, stsClient);
}
}
}
+55 -13
View File
@@ -1,20 +1,11 @@
import { STSClient } from '@aws-sdk/client-sts';
import * as core from '@actions/core';
import type { Credentials } from '@aws-sdk/client-sts';
import { GetCallerIdentityCommand } from '@aws-sdk/client-sts';
import type { CredentialsClient } from './CredentialsClient';
const MAX_TAG_VALUE_LENGTH = 256;
const SANITIZATION_CHARACTER = '_';
let stsclient: STSClient | undefined;
export function getStsClient(region: string, customUserAgent?: string) {
if (!stsclient) {
stsclient = new STSClient({
region,
...(customUserAgent ? { customUserAgent } : {}),
});
}
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
@@ -74,3 +65,54 @@ export async function retryAndBackoff<T>(
return await retryAndBackoff(fn, isRetryable, retries, maxRetries, base);
}
}
export 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', '');
}
}
export 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);
}
export async function exportAccountId(credentialsClient: CredentialsClient, maskAccountId?: string) {
// Get the AWS account ID
const client = credentialsClient.getStsClient();
const identity = await client.send(new GetCallerIdentityCommand({}));
const accountId = identity.Account;
if (!accountId) {
throw new Error('Could not get Account ID from STS. Did you set credentials?');
}
if (maskAccountId) {
core.setSecret(accountId);
}
core.setOutput('aws-account-id', accountId);
return accountId;
}
+49 -116
View File
@@ -1,136 +1,67 @@
import * as core from '@actions/core';
import type { Credentials } from '@aws-sdk/client-sts';
import { GetCallerIdentityCommand, STSClient } from '@aws-sdk/client-sts';
import { assumeRole } from './assumeRole';
import { errorMessage, getStsClient, retryAndBackoff } from './helpers';
import { CredentialsClient } from './CredentialsClient';
import { errorMessage, retryAndBackoff, exportRegion, exportCredentials, exportAccountId } from './helpers';
// 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 DEFAULT_ROLE_DURATION = 3600; // One hour
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({}));
const accountId = identity.Account;
if (!accountId) {
throw new Error('Could not get Account ID from STS. Did you set credentials?');
}
if (maskAccountId) {
core.setSecret(accountId);
}
core.setOutput('aws-account-id', accountId);
return accountId;
}
async 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 sessionTokenInput = core.getInput('aws-session-token', { required: false });
const SessionToken = sessionTokenInput === '' ? undefined : sessionTokenInput;
const maskAccountId =
(core.getInput('mask-aws-account-id', { required: false }) || 'true').toLowerCase() === 'true';
const region = core.getInput('aws-region', { required: true });
const roleToAssume = core.getInput('role-to-assume', { required: false });
const audience = core.getInput('audience', { required: false });
const maskAccountId = core.getInput('mask-aws-account-id', { required: false });
const roleExternalId = core.getInput('role-external-id', { required: false });
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 =
!!roleToAssume && !!process.env['ACTIONS_ID_TOKEN_REQUEST_TOKEN'] && !AccessKeyId && !webIdentityTokenFile;
const roleDurationSeconds =
(parseInt(core.getInput('role-duration-seconds', { required: false })) ||
((SessionToken ? SESSION_ROLE_DURATION : undefined) ??
(useGitHubOIDCProvider ? DEFAULT_ROLE_DURATION_FOR_OIDC_ROLES : undefined))) ??
MAX_ACTION_RUNTIME;
const roleDuration = parseInt(core.getInput('role-duration-seconds', { required: false })) || DEFAULT_ROLE_DURATION;
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 proxyServer = core.getInput('http-proxy', { required: false });
const disableOIDC = core.getInput('disable-oidc', { required: false });
// Logic to decide whether to attempt to use OIDC or not
const useGitHubOIDCProvider = () => {
// The `ACTIONS_ID_TOKEN_REQUEST_TOKEN` environment variable is set when the `id-token` permission is granted,
// This is necessary to authenticate with OIDC, but not strictly set just for OIDC. If it is not set and all other
// checks pass, it is likely but not guaranteed that the user needs but lacks this permission in their workflow.
// So, we will log a warning when it is the only piece absent, as well as add an opportunity to manually disable the entire check.
if (
!!roleToAssume &&
!webIdentityTokenFile &&
!AccessKeyId &&
!disableOIDC &&
!process.env['ACTIONS_ID_TOKEN_REQUEST_TOKEN']
) {
core.info(
'It looks like you might be trying to authenticate with OIDC. Did you mean to set the `id-token` permission?'
);
}
return (
!!roleToAssume &&
!!process.env['ACTIONS_ID_TOKEN_REQUEST_TOKEN'] &&
!AccessKeyId &&
!webIdentityTokenFile &&
!disableOIDC
);
};
// Validate and export region
if (!region.match(REGION_REGEX)) {
throw new Error(`Region is not valid: ${region}`);
}
exportRegion(region);
// Instantiate credentials client
const credentialsClient = new CredentialsClient({ region, proxyServer });
// 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
@@ -140,7 +71,6 @@ export async function run() {
if (!SecretAccessKey) {
throw new Error("'aws-secret-access-key' must be provided if 'aws-access-key-id' is provided");
}
exportCredentials({ AccessKeyId, SecretAccessKey, SessionToken });
}
@@ -149,7 +79,7 @@ export async function run() {
// The only way to assume the role is via GitHub's OIDC provider.
let sourceAccountId: string;
let webIdentityToken: string;
if (useGitHubOIDCProvider) {
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 {
@@ -158,35 +88,38 @@ export async function run() {
// 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);
await credentialsClient.validateCredentials(AccessKeyId);
sourceAccountId = await exportAccountId(region, maskAccountId);
sourceAccountId = await exportAccountId(credentialsClient, maskAccountId);
}
// Get role credentials if configured to do so
if (roleToAssume) {
const roleCredentials = await retryAndBackoff(async () => {
return assumeRole({
credentialsClient,
sourceAccountId,
region,
roleToAssume,
roleExternalId,
roleDurationSeconds,
roleDuration,
roleSessionName,
roleSkipSessionTagging,
webIdentityTokenFile,
webIdentityToken,
});
}, true);
core.info(`Authenticated as assumedRoleId ${roleCredentials.AssumedRoleUser!.AssumedRoleId!}`);
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 credentialsClient.validateCredentials(roleCredentials.Credentials?.AccessKeyId);
}
await exportAccountId(region, maskAccountId);
await exportAccountId(credentialsClient, maskAccountId);
} else {
core.info('Proceeding with IAM user credentials');
}
} catch (error) {
core.setFailed(errorMessage(error));