mirror of
https://github.com/aws-actions/configure-aws-credentials.git
synced 2026-03-12 18:07:10 -04:00
feat!: initial v6 work
This commit is contained in:
@@ -12,10 +12,6 @@
|
||||
"performance": {
|
||||
"noDelete": "off"
|
||||
},
|
||||
"correctness": {
|
||||
// Specifying a radix disables interpretation of 0x as a number (needed for backwards compat)
|
||||
"useParseIntRadix": "off"
|
||||
},
|
||||
"complexity": {
|
||||
"noExtraBooleanCast": "off"
|
||||
}
|
||||
|
||||
@@ -1,104 +1,96 @@
|
||||
import { info } from '@actions/core';
|
||||
import { STSClient } from '@aws-sdk/client-sts';
|
||||
import type { AwsCredentialIdentity } from '@aws-sdk/types';
|
||||
import { NodeHttpHandler } from '@smithy/node-http-handler';
|
||||
import { ProxyAgent } from 'proxy-agent';
|
||||
import { errorMessage, getCallerIdentity } from './helpers';
|
||||
import { ProxyResolver } from './ProxyResolver';
|
||||
import {
|
||||
fromContainerMetadata,
|
||||
fromEnv,
|
||||
fromInstanceMetadata,
|
||||
fromNodeProviderChain,
|
||||
fromTemporaryCredentials,
|
||||
fromWebToken,
|
||||
} from '@aws-sdk/credential-providers';
|
||||
import type { AwsCredentialIdentityProvider } from '@aws-sdk/types';
|
||||
import type { NodeHttpHandler } from '@smithy/node-http-handler';
|
||||
|
||||
const USER_AGENT = 'configure-aws-credentials-for-github-actions';
|
||||
type EnvMode = { mode: 'env' };
|
||||
type WebIdentityMode = { mode: 'web-identity'; webIdentityToken: string } & AssumeRoleOptions;
|
||||
type StsMode = { mode: 'sts'; masterCredentials?: AwsCredentialIdentityProvider } & AssumeRoleOptions;
|
||||
type ContainerMetadataMode = { mode: 'container-metadata' };
|
||||
type InstanceMetadataMode = { mode: 'instance-metadata' };
|
||||
|
||||
export interface CredentialsClientProps {
|
||||
region?: string;
|
||||
proxyServer?: string;
|
||||
noProxy?: string;
|
||||
}
|
||||
type AssumeRoleOptions = {
|
||||
roleArn: string;
|
||||
roleSessionName?: string;
|
||||
durationSeconds?: number;
|
||||
policy?: string;
|
||||
policyArns?: { arn: string }[];
|
||||
region: string;
|
||||
requestHandler?: NodeHttpHandler;
|
||||
};
|
||||
|
||||
export type CredentialsClientProps = EnvMode | WebIdentityMode | StsMode | ContainerMetadataMode | InstanceMetadataMode;
|
||||
|
||||
export class CredentialsClient {
|
||||
public region?: string;
|
||||
private _stsClient?: STSClient;
|
||||
private readonly requestHandler?: NodeHttpHandler;
|
||||
readonly DEFAULT_ROLE_DURATION = 3600;
|
||||
readonly DEFAULT_ROLE_SESSION_NAME = 'GitHubActions';
|
||||
private provider: AwsCredentialIdentityProvider;
|
||||
|
||||
constructor(props: CredentialsClientProps) {
|
||||
if (props.region !== undefined) {
|
||||
this.region = props.region;
|
||||
}
|
||||
if (props.proxyServer) {
|
||||
info('Configuring proxy handler for STS client');
|
||||
const proxyOptions: { httpProxy: string; httpsProxy: string; noProxy?: string } = {
|
||||
httpProxy: props.proxyServer,
|
||||
httpsProxy: props.proxyServer,
|
||||
};
|
||||
if (props.noProxy !== undefined) {
|
||||
proxyOptions.noProxy = props.noProxy;
|
||||
}
|
||||
const getProxyForUrl = new ProxyResolver(proxyOptions).getProxyForUrl;
|
||||
const handler = new ProxyAgent({ getProxyForUrl });
|
||||
this.requestHandler = new NodeHttpHandler({
|
||||
httpsAgent: handler,
|
||||
httpAgent: handler,
|
||||
});
|
||||
}
|
||||
this.provider = this.createCredentialChain(props);
|
||||
}
|
||||
|
||||
public get stsClient(): STSClient {
|
||||
if (!this._stsClient) {
|
||||
const config = { customUserAgent: USER_AGENT } as {
|
||||
customUserAgent: string;
|
||||
region?: string;
|
||||
requestHandler?: NodeHttpHandler;
|
||||
};
|
||||
if (this.region !== undefined) config.region = this.region;
|
||||
if (this.requestHandler !== undefined) config.requestHandler = this.requestHandler;
|
||||
this._stsClient = new STSClient(config);
|
||||
}
|
||||
return this._stsClient;
|
||||
}
|
||||
private createCredentialChain(props: CredentialsClientProps): AwsCredentialIdentityProvider {
|
||||
const primaryProvider = this.getPrimaryProvider(props);
|
||||
const fallbackProvider = fromNodeProviderChain();
|
||||
|
||||
public async validateCredentials(
|
||||
expectedAccessKeyId?: string,
|
||||
roleChaining?: boolean,
|
||||
expectedAccountIds?: string[],
|
||||
) {
|
||||
let credentials: AwsCredentialIdentity;
|
||||
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)}`);
|
||||
}
|
||||
if (expectedAccountIds && expectedAccountIds.length > 0 && expectedAccountIds[0] !== '') {
|
||||
let callerIdentity: Awaited<ReturnType<typeof getCallerIdentity>>;
|
||||
return async () => {
|
||||
try {
|
||||
callerIdentity = await getCallerIdentity(this.stsClient);
|
||||
} catch (error) {
|
||||
throw new Error(`Could not validate account ID of credentials: ${errorMessage(error)}`);
|
||||
return await primaryProvider();
|
||||
} catch {
|
||||
return await fallbackProvider();
|
||||
}
|
||||
if (!callerIdentity.Account || !expectedAccountIds.includes(callerIdentity.Account)) {
|
||||
throw new Error(
|
||||
`The account ID of the provided credentials (${
|
||||
callerIdentity.Account ?? 'unknown'
|
||||
}) does not match any of the expected account IDs: ${expectedAccountIds.join(', ')}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
if (!roleChaining) {
|
||||
const actualAccessKeyId = credentials.accessKeyId;
|
||||
if (expectedAccessKeyId && expectedAccessKeyId !== actualAccessKeyId) {
|
||||
throw new Error(
|
||||
'Credentials loaded by the SDK do not match the expected access key ID configured by the action',
|
||||
);
|
||||
}
|
||||
private getPrimaryProvider(props: CredentialsClientProps): AwsCredentialIdentityProvider {
|
||||
switch (props.mode) {
|
||||
case 'env':
|
||||
return fromEnv();
|
||||
case 'web-identity':
|
||||
return fromWebToken({
|
||||
clientConfig: {
|
||||
region: props.region,
|
||||
...(props.requestHandler && { requestHandler: props.requestHandler }),
|
||||
maxAttempts: 1, // Disable built-in retry logic
|
||||
},
|
||||
roleArn: props.roleArn,
|
||||
webIdentityToken: props.webIdentityToken,
|
||||
durationSeconds: props.durationSeconds ?? this.DEFAULT_ROLE_DURATION,
|
||||
roleSessionName: props.roleSessionName ?? this.DEFAULT_ROLE_SESSION_NAME,
|
||||
...(props.policy && { policy: props.policy }),
|
||||
...(props.policyArns && { policyArns: props.policyArns }),
|
||||
});
|
||||
case 'sts':
|
||||
return fromTemporaryCredentials({
|
||||
params: {
|
||||
RoleArn: props.roleArn,
|
||||
DurationSeconds: props.durationSeconds ?? this.DEFAULT_ROLE_DURATION,
|
||||
RoleSessionName: props.roleSessionName ?? this.DEFAULT_ROLE_SESSION_NAME,
|
||||
...(props.policy && { Policy: props.policy }),
|
||||
...(props.policyArns && { PolicyArns: props.policyArns }),
|
||||
},
|
||||
clientConfig: {
|
||||
region: props.region,
|
||||
...(props.requestHandler && { requestHandler: props.requestHandler }),
|
||||
maxAttempts: 1, // Disable built-in retry logic
|
||||
},
|
||||
...(props.masterCredentials && { masterCredentials: props.masterCredentials }),
|
||||
});
|
||||
case 'container-metadata':
|
||||
return fromContainerMetadata();
|
||||
case 'instance-metadata':
|
||||
return fromInstanceMetadata();
|
||||
}
|
||||
}
|
||||
|
||||
private async loadCredentials() {
|
||||
const config = {} as { requestHandler?: NodeHttpHandler };
|
||||
if (this.requestHandler !== undefined) config.requestHandler = this.requestHandler;
|
||||
const client = new STSClient(config);
|
||||
return client.config.credentials();
|
||||
getCredentials(): AwsCredentialIdentityProvider {
|
||||
return this.provider;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,7 +33,7 @@ export class ProxyResolver {
|
||||
const proto = parsedUrl.protocol.split(':', 1)[0];
|
||||
if (!proto) return ''; // Don't proxy URLs without a protocol.
|
||||
const hostname = parsedUrl.host;
|
||||
const port = parseInt(parsedUrl.port || '') || DEFAULT_PORTS[proto] || 0;
|
||||
const port = Number.parseInt(parsedUrl.port || '', 10) || DEFAULT_PORTS[proto] || 0;
|
||||
|
||||
if (options?.noProxy && !this.shouldProxy(hostname, port, options.noProxy)) return '';
|
||||
if (proto === 'http' && options?.httpProxy) return options.httpProxy;
|
||||
@@ -50,7 +50,7 @@ export class ProxyResolver {
|
||||
|
||||
const parsedProxy = proxy.match(/^(.+):(\d+)$/);
|
||||
const parsedProxyHostname = parsedProxy ? parsedProxy[1] : proxy;
|
||||
const parsedProxyPort = parsedProxy?.[2] ? parseInt(parsedProxy[2]) : 0;
|
||||
const parsedProxyPort = parsedProxy?.[2] ? Number.parseInt(parsedProxy[2], 10) : 0;
|
||||
|
||||
if (parsedProxyPort && parsedProxyPort !== port) return true; // Skip if ports don't match.
|
||||
|
||||
|
||||
@@ -1,167 +0,0 @@
|
||||
import assert from 'node:assert';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import * as core from '@actions/core';
|
||||
import type { AssumeRoleCommandInput, STSClient, Tag } from '@aws-sdk/client-sts';
|
||||
import { AssumeRoleCommand, AssumeRoleWithWebIdentityCommand } from '@aws-sdk/client-sts';
|
||||
import type { CredentialsClient } from './CredentialsClient';
|
||||
import { errorMessage, isDefined, sanitizeGitHubVariables } from './helpers';
|
||||
|
||||
async function assumeRoleWithOIDC(params: AssumeRoleCommandInput, client: STSClient, webIdentityToken: string) {
|
||||
delete params.Tags;
|
||||
core.info('Assuming role with OIDC');
|
||||
try {
|
||||
const creds = await client.send(
|
||||
new AssumeRoleWithWebIdentityCommand({
|
||||
...params,
|
||||
WebIdentityToken: webIdentityToken,
|
||||
}),
|
||||
);
|
||||
return creds;
|
||||
} 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;
|
||||
const creds = await client.send(
|
||||
new AssumeRoleWithWebIdentityCommand({
|
||||
...params,
|
||||
WebIdentityToken: webIdentityToken,
|
||||
}),
|
||||
);
|
||||
return creds;
|
||||
} 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 {
|
||||
const creds = await client.send(new AssumeRoleCommand({ ...params }));
|
||||
return creds;
|
||||
} catch (error) {
|
||||
throw new Error(`Could not assume role with user credentials: ${errorMessage(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
export interface assumeRoleParams {
|
||||
credentialsClient: CredentialsClient;
|
||||
roleToAssume: string;
|
||||
roleDuration: number;
|
||||
roleSessionName: string;
|
||||
roleSkipSessionTagging?: boolean;
|
||||
sourceAccountId?: string;
|
||||
roleExternalId?: string;
|
||||
webIdentityTokenFile?: string;
|
||||
webIdentityToken?: string;
|
||||
inlineSessionPolicy?: string;
|
||||
managedSessionPolicies?: { arn: string }[];
|
||||
}
|
||||
|
||||
export async function assumeRole(params: assumeRoleParams) {
|
||||
const {
|
||||
credentialsClient,
|
||||
sourceAccountId,
|
||||
roleToAssume,
|
||||
roleExternalId,
|
||||
roleDuration,
|
||||
roleSessionName,
|
||||
roleSkipSessionTagging,
|
||||
webIdentityTokenFile,
|
||||
webIdentityToken,
|
||||
inlineSessionPolicy,
|
||||
managedSessionPolicies,
|
||||
} = { ...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?');
|
||||
}
|
||||
|
||||
// Load role session tags
|
||||
const tagArray: Tag[] = [
|
||||
{ Key: 'GitHub', Value: 'Actions' },
|
||||
{ Key: 'Repository', Value: GITHUB_REPOSITORY },
|
||||
{ Key: 'Workflow', Value: sanitizeGitHubVariables(GITHUB_WORKFLOW) },
|
||||
{ Key: 'Action', Value: GITHUB_ACTION },
|
||||
{ Key: 'Actor', Value: sanitizeGitHubVariables(GITHUB_ACTOR) },
|
||||
{ Key: 'Commit', Value: GITHUB_SHA },
|
||||
];
|
||||
if (process.env.GITHUB_REF) {
|
||||
tagArray.push({
|
||||
Key: 'Branch',
|
||||
Value: sanitizeGitHubVariables(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.`);
|
||||
}
|
||||
|
||||
// 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,
|
||||
RoleSessionName: roleSessionName,
|
||||
DurationSeconds: roleDuration,
|
||||
Tags: tags ? tags : undefined,
|
||||
ExternalId: roleExternalId ? roleExternalId : undefined,
|
||||
Policy: inlineSessionPolicy ? inlineSessionPolicy : undefined,
|
||||
PolicyArns: managedSessionPolicies?.length ? managedSessionPolicies : undefined,
|
||||
};
|
||||
const keys = Object.keys(commonAssumeRoleParams) as Array<keyof typeof commonAssumeRoleParams>;
|
||||
keys.forEach((k) => {
|
||||
if (commonAssumeRoleParams[k] === undefined) {
|
||||
delete commonAssumeRoleParams[k];
|
||||
}
|
||||
});
|
||||
|
||||
// Instantiate STS client
|
||||
const stsClient = credentialsClient.stsClient;
|
||||
|
||||
// Assume role using one of three methods
|
||||
if (!!webIdentityToken) {
|
||||
return assumeRoleWithOIDC(commonAssumeRoleParams, stsClient, webIdentityToken);
|
||||
}
|
||||
if (!!webIdentityTokenFile) {
|
||||
return assumeRoleWithWebIdentityTokenFile(
|
||||
commonAssumeRoleParams,
|
||||
stsClient,
|
||||
webIdentityTokenFile,
|
||||
GITHUB_WORKSPACE,
|
||||
);
|
||||
}
|
||||
return assumeRoleWithCredentials(commonAssumeRoleParams, stsClient);
|
||||
}
|
||||
304
src/helpers.ts
304
src/helpers.ts
@@ -1,13 +1,20 @@
|
||||
import { existsSync, readFileSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import * as core from '@actions/core';
|
||||
import type { Credentials, STSClient } from '@aws-sdk/client-sts';
|
||||
import { GetCallerIdentityCommand } from '@aws-sdk/client-sts';
|
||||
import type { CredentialsClient } from './CredentialsClient';
|
||||
import { type Credentials, GetCallerIdentityCommand, STSClient } from '@aws-sdk/client-sts';
|
||||
import type { AwsCredentialIdentity } from '@aws-sdk/types';
|
||||
import { NodeHttpHandler } from '@smithy/node-http-handler';
|
||||
import { ProxyAgent } from 'proxy-agent';
|
||||
import { ProxyResolver } from './ProxyResolver';
|
||||
|
||||
const MAX_TAG_VALUE_LENGTH = 256;
|
||||
const SANITIZATION_CHARACTER = '_';
|
||||
const SPECIAL_CHARS_REGEX = /[!@#$%^&*()_+\-=[\]{};':"\\|,.<>/?]+/;
|
||||
const DEFAULT_ROLE_DURATION = 3600; // One hour (seconds)
|
||||
const ROLE_SESSION_NAME = 'GitHubActions';
|
||||
const REGION_REGEX = /^[a-z0-9-]+$/g;
|
||||
|
||||
export function translateEnvVariables() {
|
||||
export function importEnvVariables() {
|
||||
const envVars = [
|
||||
'AWS_REGION',
|
||||
'ROLE_TO_ASSUME',
|
||||
@@ -40,56 +47,93 @@ export function translateEnvVariables() {
|
||||
}
|
||||
}
|
||||
}
|
||||
export function getActionInputs() {
|
||||
const options = {
|
||||
AccessKeyId: getInput('aws-access-key-id'),
|
||||
SecretAccessKey: getInput('aws-secret-access-key'),
|
||||
SessionToken: getInput('aws-session-token'),
|
||||
region: getInput('aws-region', { required: true }) as string,
|
||||
roleToAssume: getInput('role-to-assume'),
|
||||
audience: getInput('audience'),
|
||||
maskAccountId: getBooleanInput('mask-aws-account-id'),
|
||||
roleExternalId: getInput('role-external-id'),
|
||||
webIdentityTokenFile: getInput('web-identity-token-file'),
|
||||
roleDuration: getNumberInput('role-duration-seconds', { default: DEFAULT_ROLE_DURATION }),
|
||||
roleSessionName: getInput('role-session-name', { default: ROLE_SESSION_NAME }),
|
||||
roleSkipSessionTagging: getBooleanInput('role-skip-session-tagging'),
|
||||
proxyServer: getInput('http-proxy', { default: process.env.HTTP_PROXY }),
|
||||
inlineSessionPolicy: getInput('inline-session-policy'),
|
||||
managedSessionPolicies: getMultilineInput('managed-session-policies')?.map((p) => ({ arn: p })),
|
||||
roleChaining: getBooleanInput('role-chaining'),
|
||||
outputCredentials: getBooleanInput('output-credentials'),
|
||||
unsetCurrentCredentials: getBooleanInput('unset-current-credentials'),
|
||||
exportEnvCredentials: getBooleanInput('output-env-credentials', { default: true }),
|
||||
disableRetry: getBooleanInput('disable-retry'),
|
||||
maxRetries: getNumberInput('retry-max-attempts', { default: 12 }) as number,
|
||||
specialCharacterWorkaround: getBooleanInput('special-characters-workaround'),
|
||||
useExistingCredentials: getBooleanInput('use-existing-credentials'),
|
||||
expectedAccountIds: getInput('allowed-account-ids')
|
||||
?.split(',')
|
||||
.map((s) => s.trim()),
|
||||
forceSkipOidc: getBooleanInput('force-skip-oidc'),
|
||||
noProxy: getInput('no-proxy'),
|
||||
globalTimeout: getNumberInput('action-timeout-s', { default: 0 }) as number,
|
||||
} as const;
|
||||
// Checks
|
||||
if (options.forceSkipOidc && options.roleToAssume && !options.AccessKeyId && !options.webIdentityTokenFile) {
|
||||
throw new Error(
|
||||
"If 'force-skip-oidc' is true and 'role-to-assume' is set, 'aws-access-key-id' or 'web-identity-token-file' must be set",
|
||||
);
|
||||
}
|
||||
if (!options.roleToAssume && options.AccessKeyId && !options.SecretAccessKey) {
|
||||
throw new Error(
|
||||
"'aws-secret-access-key' must be provided if 'aws-access-key-id' is provided and not assuming a role",
|
||||
);
|
||||
}
|
||||
if (!options.region?.match(REGION_REGEX)) {
|
||||
throw new Error(`Region is not valid: ${options.region}`);
|
||||
}
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
// 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
|
||||
export function exportCredentials(
|
||||
creds?: Partial<Credentials>,
|
||||
outputCredentials?: boolean,
|
||||
outputEnvCredentials?: boolean,
|
||||
) {
|
||||
export function exportCredentials(creds?: Partial<Credentials>) {
|
||||
if (creds?.AccessKeyId) {
|
||||
core.setSecret(creds.AccessKeyId);
|
||||
core.exportVariable('AWS_ACCESS_KEY_ID', creds.AccessKeyId);
|
||||
}
|
||||
|
||||
if (creds?.SecretAccessKey) {
|
||||
core.setSecret(creds.SecretAccessKey);
|
||||
core.exportVariable('AWS_SECRET_ACCESS_KEY', creds.SecretAccessKey);
|
||||
}
|
||||
|
||||
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', '');
|
||||
}
|
||||
}
|
||||
|
||||
if (outputEnvCredentials) {
|
||||
if (creds?.AccessKeyId) {
|
||||
core.exportVariable('AWS_ACCESS_KEY_ID', creds.AccessKeyId);
|
||||
}
|
||||
|
||||
if (creds?.SecretAccessKey) {
|
||||
core.exportVariable('AWS_SECRET_ACCESS_KEY', creds.SecretAccessKey);
|
||||
}
|
||||
|
||||
if (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 outputCredentials(creds?: Partial<Credentials>) {
|
||||
if (creds?.AccessKeyId) {
|
||||
core.setSecret(creds.AccessKeyId);
|
||||
core.setOutput('aws-access-key-id', creds.AccessKeyId);
|
||||
}
|
||||
|
||||
if (outputCredentials) {
|
||||
if (creds?.AccessKeyId) {
|
||||
core.setOutput('aws-access-key-id', creds.AccessKeyId);
|
||||
}
|
||||
if (creds?.SecretAccessKey) {
|
||||
core.setOutput('aws-secret-access-key', creds.SecretAccessKey);
|
||||
}
|
||||
if (creds?.SessionToken) {
|
||||
core.setOutput('aws-session-token', creds.SessionToken);
|
||||
}
|
||||
if (creds?.Expiration) {
|
||||
core.setOutput('aws-expiration', creds.Expiration);
|
||||
}
|
||||
if (creds?.SecretAccessKey) {
|
||||
core.setSecret(creds.SecretAccessKey);
|
||||
core.setOutput('aws-secret-access-key', creds.SecretAccessKey);
|
||||
}
|
||||
if (creds?.SessionToken) {
|
||||
core.setSecret(creds.SessionToken);
|
||||
core.setOutput('aws-session-token', creds.SessionToken);
|
||||
}
|
||||
if (creds?.Expiration) {
|
||||
core.setOutput('aws-expiration', creds.Expiration);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -103,14 +147,17 @@ export function unsetCredentials(outputEnvCredentials?: boolean) {
|
||||
}
|
||||
}
|
||||
|
||||
export function exportRegion(region: string, outputEnvCredentials?: boolean) {
|
||||
if (outputEnvCredentials) {
|
||||
core.exportVariable('AWS_DEFAULT_REGION', region);
|
||||
core.exportVariable('AWS_REGION', region);
|
||||
}
|
||||
export function exportRegion(region: string) {
|
||||
core.exportVariable('AWS_DEFAULT_REGION', region);
|
||||
core.exportVariable('AWS_REGION', region);
|
||||
}
|
||||
|
||||
export async function getCallerIdentity(client: STSClient): Promise<{ Account: string; Arn: string; UserId?: string }> {
|
||||
export function outputRegion(region: string) {
|
||||
core.setOutput('aws-region', region);
|
||||
core.setOutput('aws-default-region', region);
|
||||
}
|
||||
|
||||
async function getCallerIdentity(client: STSClient): Promise<{ Account: string; Arn: string; UserId?: string }> {
|
||||
const identity = await client.send(new GetCallerIdentityCommand({}));
|
||||
if (!identity.Account || !identity.Arn) {
|
||||
throw new Error('Could not get Account ID or ARN from STS. Did you set credentials?');
|
||||
@@ -125,18 +172,37 @@ export async function getCallerIdentity(client: STSClient): Promise<{ Account: s
|
||||
return result;
|
||||
}
|
||||
|
||||
// Obtains account ID from STS Client and sets it as output
|
||||
export async function exportAccountId(credentialsClient: CredentialsClient, maskAccountId?: boolean) {
|
||||
const identity = await getCallerIdentity(credentialsClient.stsClient);
|
||||
const accountId = identity.Account;
|
||||
const arn = identity.Arn;
|
||||
if (maskAccountId) {
|
||||
export function outputAccountId(accountId: string, opts: { arn?: string | undefined; maskAccountId?: boolean }) {
|
||||
if (opts.maskAccountId) {
|
||||
core.setSecret(accountId);
|
||||
core.setSecret(arn);
|
||||
if (opts.arn) core.setSecret(opts.arn);
|
||||
}
|
||||
core.setOutput('aws-account-id', accountId);
|
||||
core.setOutput('authenticated-arn', arn);
|
||||
return accountId;
|
||||
core.setOutput('authenticated-arn', opts.arn);
|
||||
}
|
||||
|
||||
export function exportAccountId(accountId: string, opts: { maskAccountId?: boolean }) {
|
||||
if (opts.maskAccountId) {
|
||||
core.setSecret(accountId);
|
||||
}
|
||||
core.exportVariable('AWS_ACCOUNT_ID', accountId);
|
||||
}
|
||||
|
||||
// Obtains account ID and AssumedRole ARN from STS
|
||||
export async function getAccountIdFromCredentials(
|
||||
credentials: AwsCredentialIdentity,
|
||||
args?: { requestHandler?: NodeHttpHandler; region?: string },
|
||||
): Promise<{ Account: string; Arn?: string | undefined } | undefined> {
|
||||
const requestHandler = args?.requestHandler;
|
||||
const region = args?.region;
|
||||
const client = new STSClient({ credentials, ...(requestHandler && { requestHandler }), ...(region && { region }) });
|
||||
try {
|
||||
const result = await getCallerIdentity(client);
|
||||
return { Account: result.Account, Arn: result.Arn };
|
||||
} catch (error) {
|
||||
core.debug(`getAccountIdFromCredentials: ${errorMessage(error)}`);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
// Tags have a more restrictive set of acceptable characters than GitHub environment variables can.
|
||||
@@ -148,20 +214,7 @@ export function sanitizeGitHubVariables(name: string) {
|
||||
return nameTruncated;
|
||||
}
|
||||
|
||||
export async function defaultSleep(ms: number) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
let sleep = defaultSleep;
|
||||
|
||||
export function withsleep(s: typeof sleep) {
|
||||
sleep = s;
|
||||
}
|
||||
|
||||
export function reset() {
|
||||
sleep = defaultSleep;
|
||||
}
|
||||
|
||||
export function verifyKeys(creds: Partial<Credentials> | undefined) {
|
||||
export function verifyKeys(creds: { AccessKeyId?: string; SecretAccessKey?: string } | undefined) {
|
||||
if (!creds) {
|
||||
return false;
|
||||
}
|
||||
@@ -204,7 +257,7 @@ export async function retryAndBackoff<T>(
|
||||
`Retrying after ${Math.floor(delay)}ms.`,
|
||||
);
|
||||
|
||||
await sleep(delay);
|
||||
await new Promise((r) => setTimeout(r, delay));
|
||||
|
||||
if (nextRetry >= maxRetries) {
|
||||
core.debug('retryAndBackoff: reached max retries; giving up.');
|
||||
@@ -215,20 +268,18 @@ export async function retryAndBackoff<T>(
|
||||
}
|
||||
}
|
||||
|
||||
/* c8 ignore start */
|
||||
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;
|
||||
}
|
||||
/* c8 ignore stop */
|
||||
|
||||
export async function areCredentialsValid(credentialsClient: CredentialsClient) {
|
||||
const client = credentialsClient.stsClient;
|
||||
// Note: requires that credentials are already resolvable with the default chain.
|
||||
export async function areCredentialsValid(args: { requestHandler?: NodeHttpHandler; region?: string }) {
|
||||
// GetCallerIdentity does not require any permissions
|
||||
const requestHandler = args.requestHandler;
|
||||
const region = args.region;
|
||||
const client = new STSClient({ ...(requestHandler && { requestHandler }), ...(region && { region }) });
|
||||
try {
|
||||
const identity = await client.send(new GetCallerIdentityCommand({}));
|
||||
const identity = await getCallerIdentity(client);
|
||||
if (identity.Account) {
|
||||
return true;
|
||||
}
|
||||
@@ -238,6 +289,21 @@ export async function areCredentialsValid(credentialsClient: CredentialsClient)
|
||||
}
|
||||
}
|
||||
|
||||
export function configureProxy(proxyServer?: string, noProxy?: string): NodeHttpHandler {
|
||||
if (proxyServer) {
|
||||
core.info('Configuring proxy handler');
|
||||
const proxyOptions: { httpProxy: string; httpsProxy: string; noProxy?: string } = {
|
||||
httpProxy: proxyServer,
|
||||
httpsProxy: proxyServer,
|
||||
...(noProxy && { noProxy }),
|
||||
};
|
||||
const getProxyForUrl = new ProxyResolver(proxyOptions).getProxyForUrl;
|
||||
const handler = new ProxyAgent({ getProxyForUrl });
|
||||
return new NodeHttpHandler({ httpsAgent: handler, httpAgent: handler });
|
||||
}
|
||||
return new NodeHttpHandler();
|
||||
}
|
||||
|
||||
/**
|
||||
* Like core.getBooleanInput, but respects the required option.
|
||||
*
|
||||
@@ -266,3 +332,87 @@ export function getBooleanInput(name: string, options?: core.InputOptions & { de
|
||||
`Support boolean input list: \`true | True | TRUE | false | False | FALSE\``,
|
||||
);
|
||||
}
|
||||
|
||||
// As above, but for other input types
|
||||
export function getInput(
|
||||
name: string,
|
||||
options?: core.InputOptions & { default?: string | undefined },
|
||||
): string | undefined {
|
||||
const input = core.getInput(name, options);
|
||||
if (input === '') return options?.default;
|
||||
return input;
|
||||
}
|
||||
export function getNumberInput(
|
||||
name: string,
|
||||
options?: core.InputOptions & { default?: number | undefined },
|
||||
): number | undefined {
|
||||
const input = core.getInput(name, options);
|
||||
if (input === '') return options?.default;
|
||||
// biome-ignore lint/correctness/useParseIntRadix: intentionally support 0x
|
||||
return Number.parseInt(input);
|
||||
}
|
||||
export function getMultilineInput(name: string, options?: core.InputOptions): string[] | undefined {
|
||||
const input = getInput(name, options)
|
||||
?.split('\n')
|
||||
.filter((i) => i !== '');
|
||||
if (options?.trimWhitespace === false) return input;
|
||||
return input?.map((i) => i.trim());
|
||||
}
|
||||
|
||||
export function useGitHubOIDCProvider(options: ReturnType<typeof getActionInputs>): boolean {
|
||||
if (options.forceSkipOidc) return false;
|
||||
if (
|
||||
!!options.roleToAssume &&
|
||||
!options.webIdentityTokenFile &&
|
||||
!options.AccessKeyId &&
|
||||
!process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN &&
|
||||
!options.roleChaining
|
||||
) {
|
||||
core.info(
|
||||
'It looks like you might be trying to authenticate with OIDC. Did you mean to set the `id-token` permission? ' +
|
||||
'If you are not trying to authenticate with OIDC and the action is working successfully, you can ignore this message.',
|
||||
);
|
||||
}
|
||||
return (
|
||||
!!options.roleToAssume &&
|
||||
!process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN &&
|
||||
!options.AccessKeyId &&
|
||||
!options.webIdentityTokenFile &&
|
||||
!options.roleChaining
|
||||
);
|
||||
}
|
||||
|
||||
export async function getGHToken(options: ReturnType<typeof getActionInputs>): Promise<string> {
|
||||
let webIdentityToken: string;
|
||||
try {
|
||||
webIdentityToken = await retryAndBackoff(
|
||||
async () => core.getIDToken(options.audience),
|
||||
!options.disableRetry,
|
||||
options.maxRetries,
|
||||
);
|
||||
} catch (error) {
|
||||
throw new Error(`getIDToken call failed: ${errorMessage(error)}`);
|
||||
}
|
||||
return webIdentityToken;
|
||||
}
|
||||
|
||||
export async function getFileToken(options: ReturnType<typeof getActionInputs>): Promise<string> {
|
||||
if (!options.webIdentityTokenFile) throw new Error('webIdentityTokenFile not provided');
|
||||
core.debug(
|
||||
'webIdentityTokenFile provided. Will call sts:AssumeRoleWithWebIdentity and take session tags from token contents.',
|
||||
);
|
||||
const workspace = process.env.GITHUB_WORKSPACE ?? '';
|
||||
const webIdentityTokenFilePath = path.isAbsolute(options.webIdentityTokenFile)
|
||||
? options.webIdentityTokenFile
|
||||
: path.join(workspace, options.webIdentityTokenFile);
|
||||
if (!existsSync(webIdentityTokenFilePath)) {
|
||||
throw new Error(`webIdentityTokenFile does not exist: ${webIdentityTokenFilePath}`);
|
||||
}
|
||||
core.info('Assuming role with web identity token file');
|
||||
try {
|
||||
const webIdentityToken = readFileSync(webIdentityTokenFilePath, 'utf8');
|
||||
return webIdentityToken;
|
||||
} catch (error) {
|
||||
throw new Error(`Could not read web identity token file: ${errorMessage(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
308
src/index.ts
308
src/index.ts
@@ -1,136 +1,58 @@
|
||||
import * as core from '@actions/core';
|
||||
import type { AssumeRoleCommandOutput } from '@aws-sdk/client-sts';
|
||||
import { assumeRole } from './assumeRole';
|
||||
import { CredentialsClient } from './CredentialsClient';
|
||||
import type { AwsCredentialIdentity } from '@aws-sdk/types';
|
||||
import { CredentialsClient, type CredentialsClientProps } from './CredentialsClient';
|
||||
import {
|
||||
areCredentialsValid,
|
||||
configureProxy,
|
||||
errorMessage,
|
||||
exportAccountId,
|
||||
exportCredentials,
|
||||
exportRegion,
|
||||
getBooleanInput,
|
||||
getAccountIdFromCredentials,
|
||||
getActionInputs,
|
||||
getFileToken,
|
||||
getGHToken,
|
||||
importEnvVariables,
|
||||
outputAccountId,
|
||||
outputCredentials,
|
||||
outputRegion,
|
||||
retryAndBackoff,
|
||||
translateEnvVariables,
|
||||
unsetCredentials,
|
||||
useGitHubOIDCProvider,
|
||||
verifyKeys,
|
||||
} from './helpers';
|
||||
|
||||
const DEFAULT_ROLE_DURATION = 3600; // One hour (seconds)
|
||||
const ROLE_SESSION_NAME = 'GitHubActions';
|
||||
const REGION_REGEX = /^[a-z0-9-]+$/g;
|
||||
|
||||
export async function run() {
|
||||
export async function run(): Promise<void> {
|
||||
try {
|
||||
translateEnvVariables();
|
||||
// Get inputs
|
||||
// Undefined inputs are empty strings ( or empty arrays)
|
||||
const AccessKeyId = core.getInput('aws-access-key-id', { required: false });
|
||||
const SecretAccessKey = core.getInput('aws-secret-access-key', { required: false });
|
||||
const sessionTokenInput = core.getInput('aws-session-token', { required: false });
|
||||
const SessionToken = sessionTokenInput === '' ? undefined : sessionTokenInput;
|
||||
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 = getBooleanInput('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 });
|
||||
const roleDuration =
|
||||
Number.parseInt(core.getInput('role-duration-seconds', { required: false })) || DEFAULT_ROLE_DURATION;
|
||||
const roleSessionName = core.getInput('role-session-name', { required: false }) || ROLE_SESSION_NAME;
|
||||
const roleSkipSessionTagging = getBooleanInput('role-skip-session-tagging', { required: false });
|
||||
const proxyServer = core.getInput('http-proxy', { required: false }) || process.env.HTTP_PROXY;
|
||||
const inlineSessionPolicy = core.getInput('inline-session-policy', { required: false });
|
||||
const managedSessionPolicies = core.getMultilineInput('managed-session-policies', { required: false }).map((p) => {
|
||||
return { arn: p };
|
||||
});
|
||||
const roleChaining = getBooleanInput('role-chaining', { required: false });
|
||||
const outputCredentials = getBooleanInput('output-credentials', { required: false });
|
||||
const outputEnvCredentials = getBooleanInput('output-env-credentials', { required: false, default: true });
|
||||
const unsetCurrentCredentials = getBooleanInput('unset-current-credentials', { required: false });
|
||||
let disableRetry = getBooleanInput('disable-retry', { required: false });
|
||||
const specialCharacterWorkaround = getBooleanInput('special-characters-workaround', { required: false });
|
||||
const useExistingCredentials = core.getInput('use-existing-credentials', { required: false });
|
||||
let maxRetries = Number.parseInt(core.getInput('retry-max-attempts', { required: false })) || 12;
|
||||
const expectedAccountIds = core
|
||||
.getInput('allowed-account-ids', { required: false })
|
||||
.split(',')
|
||||
.map((s) => s.trim());
|
||||
const forceSkipOidc = getBooleanInput('force-skip-oidc', { required: false });
|
||||
const noProxy = core.getInput('no-proxy', { required: false });
|
||||
const globalTimeout = Number.parseInt(core.getInput('action-timeout-s', { required: false })) || 0;
|
||||
// Set up inputs
|
||||
importEnvVariables();
|
||||
const options = getActionInputs();
|
||||
let maxRetries = options.maxRetries;
|
||||
let disableRetry = options.disableRetry;
|
||||
|
||||
let timeoutId: NodeJS.Timeout | undefined;
|
||||
if (globalTimeout > 0) {
|
||||
core.info(`Setting a global timeout of ${globalTimeout} seconds for the action`);
|
||||
timeoutId = setTimeout(() => {
|
||||
core.setFailed(`Action timed out after ${globalTimeout} seconds`);
|
||||
process.exit(1);
|
||||
}, globalTimeout * 1000);
|
||||
}
|
||||
|
||||
if (forceSkipOidc && roleToAssume && !AccessKeyId && !webIdentityTokenFile) {
|
||||
throw new Error(
|
||||
"If 'force-skip-oidc' is true and 'role-to-assume' is set, 'aws-access-key-id' or 'web-identity-token-file' must be set",
|
||||
);
|
||||
}
|
||||
|
||||
if (specialCharacterWorkaround) {
|
||||
// 😳
|
||||
// Set up retry handler
|
||||
if (maxRetries < 1) maxRetries = 1;
|
||||
if (options.specialCharacterWorkaround) {
|
||||
disableRetry = false;
|
||||
maxRetries = 12;
|
||||
} else if (maxRetries < 1) {
|
||||
maxRetries = 1;
|
||||
if (maxRetries <= 1) maxRetries = 12;
|
||||
}
|
||||
|
||||
// Logic to decide whether to attempt to use OIDC or not
|
||||
const useGitHubOIDCProvider = () => {
|
||||
if (forceSkipOidc) return false;
|
||||
// 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
|
||||
if (
|
||||
!!roleToAssume &&
|
||||
!webIdentityTokenFile &&
|
||||
!AccessKeyId &&
|
||||
!process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN &&
|
||||
!roleChaining
|
||||
) {
|
||||
core.info(
|
||||
'It looks like you might be trying to authenticate with OIDC. Did you mean to set the `id-token` permission? ' +
|
||||
'If you are not trying to authenticate with OIDC and the action is working successfully, you can ignore this message.',
|
||||
);
|
||||
}
|
||||
return (
|
||||
!!roleToAssume &&
|
||||
!!process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN &&
|
||||
!AccessKeyId &&
|
||||
!webIdentityTokenFile &&
|
||||
!roleChaining
|
||||
);
|
||||
};
|
||||
|
||||
if (unsetCurrentCredentials) {
|
||||
unsetCredentials(outputEnvCredentials);
|
||||
// Set up global timeout
|
||||
let timeoutId: NodeJS.Timeout | undefined;
|
||||
if (options.globalTimeout > 0) {
|
||||
core.info(`Setting a global timeout of ${options.globalTimeout} seconds for the action`);
|
||||
timeoutId = setTimeout(() => {
|
||||
core.setFailed(`Action timed out after ${options.globalTimeout} seconds`);
|
||||
process.exit(1);
|
||||
}, options.globalTimeout * 1000);
|
||||
}
|
||||
|
||||
if (!region.match(REGION_REGEX)) {
|
||||
throw new Error(`Region is not valid: ${region}`);
|
||||
}
|
||||
exportRegion(region, outputEnvCredentials);
|
||||
// Set up proxy.
|
||||
const requestHandler = configureProxy(options.proxyServer, options.noProxy);
|
||||
|
||||
// Instantiate credentials client
|
||||
const clientProps: { region: string; proxyServer?: string; noProxy?: string } = { region };
|
||||
if (proxyServer) clientProps.proxyServer = proxyServer;
|
||||
if (noProxy) clientProps.noProxy = noProxy;
|
||||
const credentialsClient = new CredentialsClient(clientProps);
|
||||
let sourceAccountId: string;
|
||||
let webIdentityToken: string;
|
||||
|
||||
//if the user wants to attempt to use existing credentials, check if we have some already
|
||||
if (useExistingCredentials) {
|
||||
const validCredentials = await areCredentialsValid(credentialsClient);
|
||||
if (validCredentials) {
|
||||
// if the user wants to attempt to use existing credentials, check if we have some already.
|
||||
// Requires STS. Uses default credential provider resolution chain.
|
||||
if (options.useExistingCredentials) {
|
||||
if (await areCredentialsValid({ requestHandler, region: options.region })) {
|
||||
core.notice('Pre-existing credentials are valid. No need to generate new ones.');
|
||||
if (timeoutId) clearTimeout(timeoutId);
|
||||
return;
|
||||
@@ -138,105 +60,91 @@ export async function run() {
|
||||
core.notice('No valid credentials exist. Running as normal.');
|
||||
}
|
||||
|
||||
// If OIDC is being used, generate token
|
||||
// Else, export credentials provided as input
|
||||
if (useGitHubOIDCProvider()) {
|
||||
try {
|
||||
webIdentityToken = await retryAndBackoff(
|
||||
async () => {
|
||||
return core.getIDToken(audience);
|
||||
},
|
||||
!disableRetry,
|
||||
maxRetries,
|
||||
);
|
||||
} catch (error) {
|
||||
throw new Error(`getIDToken call failed: ${errorMessage(error)}`);
|
||||
}
|
||||
} else if (AccessKeyId) {
|
||||
if (!SecretAccessKey) {
|
||||
throw new Error("'aws-secret-access-key' must be provided if 'aws-access-key-id' is provided");
|
||||
}
|
||||
// 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 to already be masked as secrets
|
||||
// in any error messages.
|
||||
exportCredentials({ AccessKeyId, SecretAccessKey, SessionToken }, outputCredentials, outputEnvCredentials);
|
||||
} else if (!webIdentityTokenFile && !roleChaining) {
|
||||
// Proceed only if credentials can be picked up
|
||||
await credentialsClient.validateCredentials(undefined, roleChaining, expectedAccountIds);
|
||||
sourceAccountId = await exportAccountId(credentialsClient, maskAccountId);
|
||||
}
|
||||
// Set which flow to use
|
||||
// Refer to README.md for which flow to use based on set inputs
|
||||
const shouldUseGHOIDC = useGitHubOIDCProvider(options); // Checks for id-token permission
|
||||
const shouldUseTokenFile = !!options.webIdentityTokenFile;
|
||||
|
||||
if (AccessKeyId || roleChaining) {
|
||||
// Validate that the SDK can actually pick up credentials.
|
||||
// This validates cases where this action is using existing environment credentials,
|
||||
// and cases where the user intended to provide input credentials but the secrets inputs resolved to empty strings.
|
||||
await credentialsClient.validateCredentials(AccessKeyId, roleChaining, expectedAccountIds);
|
||||
sourceAccountId = await exportAccountId(credentialsClient, maskAccountId);
|
||||
}
|
||||
|
||||
// Get role credentials if configured to do so
|
||||
if (roleToAssume) {
|
||||
let roleCredentials: AssumeRoleCommandOutput;
|
||||
do {
|
||||
roleCredentials = await retryAndBackoff(
|
||||
async () => {
|
||||
return assumeRole({
|
||||
credentialsClient,
|
||||
sourceAccountId,
|
||||
roleToAssume,
|
||||
roleExternalId,
|
||||
roleDuration,
|
||||
roleSessionName,
|
||||
roleSkipSessionTagging,
|
||||
webIdentityTokenFile,
|
||||
webIdentityToken,
|
||||
inlineSessionPolicy,
|
||||
managedSessionPolicies,
|
||||
});
|
||||
},
|
||||
!disableRetry,
|
||||
maxRetries,
|
||||
);
|
||||
} while (specialCharacterWorkaround && !verifyKeys(roleCredentials.Credentials));
|
||||
core.info(`Authenticated as assumedRoleId ${roleCredentials.AssumedRoleUser?.AssumedRoleId}`);
|
||||
exportCredentials(roleCredentials.Credentials, outputCredentials, outputEnvCredentials);
|
||||
// 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 credentialsClient.validateCredentials(
|
||||
roleCredentials.Credentials?.AccessKeyId,
|
||||
roleChaining,
|
||||
expectedAccountIds,
|
||||
);
|
||||
}
|
||||
if (outputEnvCredentials) {
|
||||
await exportAccountId(credentialsClient, maskAccountId);
|
||||
let clientProps: CredentialsClientProps;
|
||||
if (options.roleToAssume) {
|
||||
const assumeRoleProps = {
|
||||
roleArn: options.roleToAssume,
|
||||
...(options.roleSessionName && { roleSessionName: options.roleSessionName }),
|
||||
...(options.roleDuration && { durationSeconds: options.roleDuration }),
|
||||
region: options.region,
|
||||
requestHandler,
|
||||
...(options.inlineSessionPolicy && { policy: options.inlineSessionPolicy }),
|
||||
...(options.managedSessionPolicies && { policyArns: options.managedSessionPolicies }),
|
||||
};
|
||||
if (shouldUseGHOIDC || shouldUseTokenFile) {
|
||||
const token = shouldUseGHOIDC ? await getGHToken(options) : await getFileToken(options);
|
||||
clientProps = { mode: 'web-identity', webIdentityToken: token, ...assumeRoleProps };
|
||||
} else {
|
||||
clientProps = { mode: 'sts', ...assumeRoleProps };
|
||||
}
|
||||
} else {
|
||||
core.info('Proceeding with IAM user credentials');
|
||||
// TODO: falling back to env only is a breaking change. Decide if fallback to the default
|
||||
// chain is better.
|
||||
clientProps = { mode: 'env' };
|
||||
disableRetry = true; // Retrying the environment credentials is invalid
|
||||
}
|
||||
// TODO: add mode 'instance-metadata' and 'container-metadata' here
|
||||
|
||||
// Get the credentials
|
||||
const fetchCredentials = new CredentialsClient(clientProps).getCredentials();
|
||||
let credentials: AwsCredentialIdentity;
|
||||
do {
|
||||
credentials = await retryAndBackoff(async () => await fetchCredentials(), !disableRetry, maxRetries);
|
||||
} while (
|
||||
options.specialCharacterWorkaround &&
|
||||
!verifyKeys({ AccessKeyId: credentials.accessKeyId, SecretAccessKey: credentials.secretAccessKey })
|
||||
);
|
||||
|
||||
// Not all credential sources have a account ID attached, so this may require another STS call
|
||||
const accountId = await getAccountIdFromCredentials(credentials, { region: options.region, requestHandler });
|
||||
if (options.expectedAccountIds) {
|
||||
if (!options.expectedAccountIds.includes(accountId?.Account ?? '')) {
|
||||
throw new Error(`Account ID ${accountId?.Account} is not in the list of expected account IDs`);
|
||||
}
|
||||
}
|
||||
|
||||
// Clear timeout on successful completion
|
||||
// Export the credentials
|
||||
// Note: output refers to action outputs, export refers to process.env
|
||||
if (options.exportEnvCredentials) {
|
||||
exportCredentials({
|
||||
AccessKeyId: credentials.accessKeyId,
|
||||
SecretAccessKey: credentials.secretAccessKey,
|
||||
Expiration: credentials.expiration,
|
||||
SessionToken: credentials.sessionToken,
|
||||
});
|
||||
exportRegion(options.region);
|
||||
if (accountId) exportAccountId(accountId.Account, { maskAccountId: options.maskAccountId });
|
||||
}
|
||||
if (options.outputCredentials) {
|
||||
outputCredentials({
|
||||
AccessKeyId: credentials.accessKeyId,
|
||||
SecretAccessKey: credentials.secretAccessKey,
|
||||
Expiration: credentials.expiration,
|
||||
SessionToken: credentials.sessionToken,
|
||||
});
|
||||
outputRegion(options.region);
|
||||
if (accountId) {
|
||||
outputAccountId(accountId.Account, { arn: accountId.Arn, maskAccountId: options.maskAccountId });
|
||||
}
|
||||
}
|
||||
|
||||
// Clear global timeout
|
||||
if (timeoutId) clearTimeout(timeoutId);
|
||||
} catch (error) {
|
||||
core.setFailed(errorMessage(error));
|
||||
|
||||
const showStackTrace = process.env.SHOW_STACK_TRACE;
|
||||
if (showStackTrace === 'true') {
|
||||
throw error;
|
||||
}
|
||||
if (process.env.SHOW_STACK_TRACE) throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/* c8 ignore start */
|
||||
/* istanbul ignore next */
|
||||
if (require.main === module) {
|
||||
(async () => {
|
||||
await run();
|
||||
})().catch((error) => {
|
||||
core.setFailed(errorMessage(error));
|
||||
})().catch((e) => {
|
||||
core.setFailed(errorMessage(e));
|
||||
});
|
||||
}
|
||||
|
||||
243
src/index.ts.old
Normal file
243
src/index.ts.old
Normal file
@@ -0,0 +1,243 @@
|
||||
import * as core from '@actions/core';
|
||||
import type { AssumeRoleCommandOutput } from '@aws-sdk/client-sts';
|
||||
import { assumeRole } from './assumeRole';
|
||||
import { CredentialsClient } from './CredentialsClient';
|
||||
import {
|
||||
areCredentialsValid,
|
||||
errorMessage,
|
||||
exportAccountId,
|
||||
exportCredentials,
|
||||
exportRegion,
|
||||
getBooleanInput,
|
||||
retryAndBackoff,
|
||||
importEnvVariables,
|
||||
unsetCredentials,
|
||||
verifyKeys,
|
||||
} from './helpers';
|
||||
|
||||
const DEFAULT_ROLE_DURATION = 3600; // One hour (seconds)
|
||||
const ROLE_SESSION_NAME = 'GitHubActions';
|
||||
const REGION_REGEX = /^[a-z0-9-]+$/g;
|
||||
|
||||
export async function run() {
|
||||
try {
|
||||
importEnvVariables();
|
||||
// Get inputs
|
||||
// Undefined inputs are empty strings ( or empty arrays)
|
||||
const AccessKeyId = core.getInput('aws-access-key-id', { required: false });
|
||||
const SecretAccessKey = core.getInput('aws-secret-access-key', { required: false });
|
||||
const sessionTokenInput = core.getInput('aws-session-token', { required: false });
|
||||
const SessionToken = sessionTokenInput === '' ? undefined : sessionTokenInput;
|
||||
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 = getBooleanInput('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 });
|
||||
const roleDuration =
|
||||
Number.parseInt(core.getInput('role-duration-seconds', { required: false })) || DEFAULT_ROLE_DURATION;
|
||||
const roleSessionName = core.getInput('role-session-name', { required: false }) || ROLE_SESSION_NAME;
|
||||
const roleSkipSessionTagging = getBooleanInput('role-skip-session-tagging', { required: false });
|
||||
const proxyServer = core.getInput('http-proxy', { required: false }) || process.env.HTTP_PROXY;
|
||||
const inlineSessionPolicy = core.getInput('inline-session-policy', { required: false });
|
||||
const managedSessionPolicies = core.getMultilineInput('managed-session-policies', { required: false }).map((p) => {
|
||||
return { arn: p };
|
||||
});
|
||||
const roleChaining = getBooleanInput('role-chaining', { required: false });
|
||||
const outputCredentials = getBooleanInput('output-credentials', { required: false });
|
||||
const outputEnvCredentials = getBooleanInput('output-env-credentials', { required: false, default: true });
|
||||
const unsetCurrentCredentials = getBooleanInput('unset-current-credentials', { required: false });
|
||||
let disableRetry = getBooleanInput('disable-retry', { required: false });
|
||||
const specialCharacterWorkaround = getBooleanInput('special-characters-workaround', { required: false });
|
||||
const useExistingCredentials = core.getInput('use-existing-credentials', { required: false });
|
||||
let maxRetries = Number.parseInt(core.getInput('retry-max-attempts', { required: false })) || 12;
|
||||
const expectedAccountIds = core
|
||||
.getInput('allowed-account-ids', { required: false })
|
||||
.split(',')
|
||||
.map((s) => s.trim());
|
||||
const forceSkipOidc = getBooleanInput('force-skip-oidc', { required: false });
|
||||
const noProxy = core.getInput('no-proxy', { required: false });
|
||||
const globalTimeout = Number.parseInt(core.getInput('action-timeout-s', { required: false })) || 0;
|
||||
|
||||
let timeoutId: NodeJS.Timeout | undefined;
|
||||
if (globalTimeout > 0) {
|
||||
core.info(`Setting a global timeout of ${globalTimeout} seconds for the action`);
|
||||
timeoutId = setTimeout(() => {
|
||||
core.setFailed(`Action timed out after ${globalTimeout} seconds`);
|
||||
process.exit(1);
|
||||
}, globalTimeout * 1000);
|
||||
}
|
||||
|
||||
if (forceSkipOidc && roleToAssume && !AccessKeyId && !webIdentityTokenFile) {
|
||||
throw new Error(
|
||||
"If 'force-skip-oidc' is true and 'role-to-assume' is set, 'aws-access-key-id' or 'web-identity-token-file' must be set",
|
||||
);
|
||||
}
|
||||
|
||||
if (specialCharacterWorkaround) {
|
||||
// 😳
|
||||
disableRetry = false;
|
||||
maxRetries = 12;
|
||||
} else if (maxRetries < 1) {
|
||||
maxRetries = 1;
|
||||
}
|
||||
|
||||
// Logic to decide whether to attempt to use OIDC or not
|
||||
const useGitHubOIDCProvider = () => {
|
||||
if (forceSkipOidc) return false;
|
||||
// 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
|
||||
if (
|
||||
!!roleToAssume &&
|
||||
!webIdentityTokenFile &&
|
||||
!AccessKeyId &&
|
||||
!process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN &&
|
||||
!roleChaining
|
||||
) {
|
||||
core.info(
|
||||
'It looks like you might be trying to authenticate with OIDC. Did you mean to set the `id-token` permission? ' +
|
||||
'If you are not trying to authenticate with OIDC and the action is working successfully, you can ignore this message.',
|
||||
);
|
||||
}
|
||||
return (
|
||||
!!roleToAssume &&
|
||||
!!process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN &&
|
||||
!AccessKeyId &&
|
||||
!webIdentityTokenFile &&
|
||||
!roleChaining
|
||||
);
|
||||
};
|
||||
|
||||
if (unsetCurrentCredentials) {
|
||||
unsetCredentials(outputEnvCredentials);
|
||||
}
|
||||
|
||||
if (!region.match(REGION_REGEX)) {
|
||||
throw new Error(`Region is not valid: ${region}`);
|
||||
}
|
||||
exportRegion(region, outputEnvCredentials);
|
||||
|
||||
// Instantiate credentials client
|
||||
const clientProps: { region: string; proxyServer?: string; noProxy?: string } = { region };
|
||||
if (proxyServer) clientProps.proxyServer = proxyServer;
|
||||
if (noProxy) clientProps.noProxy = noProxy;
|
||||
const credentialsClient = new CredentialsClient(clientProps);
|
||||
let sourceAccountId: string;
|
||||
let webIdentityToken: string;
|
||||
|
||||
//if the user wants to attempt to use existing credentials, check if we have some already
|
||||
if (useExistingCredentials) {
|
||||
//const validCredentials = await areCredentialsValid(credentialsClient);
|
||||
const validCredentials = true;
|
||||
if (validCredentials) {
|
||||
core.notice('Pre-existing credentials are valid. No need to generate new ones.');
|
||||
if (timeoutId) clearTimeout(timeoutId);
|
||||
return;
|
||||
}
|
||||
core.notice('No valid credentials exist. Running as normal.');
|
||||
}
|
||||
|
||||
// If OIDC is being used, generate token
|
||||
// Else, export credentials provided as input
|
||||
if (useGitHubOIDCProvider()) {
|
||||
try {
|
||||
webIdentityToken = await retryAndBackoff(
|
||||
async () => {
|
||||
return core.getIDToken(audience);
|
||||
},
|
||||
!disableRetry,
|
||||
maxRetries,
|
||||
);
|
||||
} catch (error) {
|
||||
throw new Error(`getIDToken call failed: ${errorMessage(error)}`);
|
||||
}
|
||||
} else if (AccessKeyId) {
|
||||
if (!SecretAccessKey) {
|
||||
throw new Error("'aws-secret-access-key' must be provided if 'aws-access-key-id' is provided");
|
||||
}
|
||||
// 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 to already be masked as secrets
|
||||
// in any error messages.
|
||||
exportCredentials({ AccessKeyId, SecretAccessKey, SessionToken }, outputCredentials, outputEnvCredentials);
|
||||
} else if (!webIdentityTokenFile && !roleChaining) {
|
||||
// Proceed only if credentials can be picked up
|
||||
await credentialsClient.validateCredentials(undefined, roleChaining, expectedAccountIds);
|
||||
sourceAccountId = await exportAccountId(credentialsClient, maskAccountId);
|
||||
}
|
||||
|
||||
if (AccessKeyId || roleChaining) {
|
||||
// Validate that the SDK can actually pick up credentials.
|
||||
// This validates cases where this action is using existing environment credentials,
|
||||
// and cases where the user intended to provide input credentials but the secrets inputs resolved to empty strings.
|
||||
await credentialsClient.validateCredentials(AccessKeyId, roleChaining, expectedAccountIds);
|
||||
sourceAccountId = await exportAccountId(credentialsClient, maskAccountId);
|
||||
}
|
||||
|
||||
// Get role credentials if configured to do so
|
||||
if (roleToAssume) {
|
||||
let roleCredentials: AssumeRoleCommandOutput;
|
||||
do {
|
||||
roleCredentials = await retryAndBackoff(
|
||||
async () => {
|
||||
return assumeRole({
|
||||
credentialsClient,
|
||||
sourceAccountId,
|
||||
roleToAssume,
|
||||
roleExternalId,
|
||||
roleDuration,
|
||||
roleSessionName,
|
||||
roleSkipSessionTagging,
|
||||
webIdentityTokenFile,
|
||||
webIdentityToken,
|
||||
inlineSessionPolicy,
|
||||
managedSessionPolicies,
|
||||
});
|
||||
},
|
||||
!disableRetry,
|
||||
maxRetries,
|
||||
);
|
||||
} while (specialCharacterWorkaround && !verifyKeys(roleCredentials.Credentials));
|
||||
core.info(`Authenticated as assumedRoleId ${roleCredentials.AssumedRoleUser?.AssumedRoleId}`);
|
||||
exportCredentials(roleCredentials.Credentials, outputCredentials, outputEnvCredentials);
|
||||
// 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 credentialsClient.validateCredentials(
|
||||
roleCredentials.Credentials?.AccessKeyId,
|
||||
roleChaining,
|
||||
expectedAccountIds,
|
||||
);
|
||||
}
|
||||
if (outputEnvCredentials) {
|
||||
await exportAccountId(credentialsClient, maskAccountId);
|
||||
}
|
||||
} else {
|
||||
core.info('Proceeding with IAM user credentials');
|
||||
}
|
||||
|
||||
// Clear timeout on successful completion
|
||||
if (timeoutId) clearTimeout(timeoutId);
|
||||
} catch (error) {
|
||||
core.setFailed(errorMessage(error));
|
||||
|
||||
const showStackTrace = process.env.SHOW_STACK_TRACE;
|
||||
if (showStackTrace === 'true') {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* c8 ignore start */
|
||||
/* istanbul ignore next */
|
||||
if (require.main === module) {
|
||||
(async () => {
|
||||
await run();
|
||||
})().catch((error) => {
|
||||
core.setFailed(errorMessage(error));
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user