diff --git a/biome.jsonc b/biome.jsonc index 8c2b9c0..762d557 100644 --- a/biome.jsonc +++ b/biome.jsonc @@ -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" } diff --git a/src/CredentialsClient.ts b/src/CredentialsClient.ts index f05b2b5..315d17c 100644 --- a/src/CredentialsClient.ts +++ b/src/CredentialsClient.ts @@ -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>; + 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; } } diff --git a/src/ProxyResolver.ts b/src/ProxyResolver.ts index 50291be..ff774c2 100644 --- a/src/ProxyResolver.ts +++ b/src/ProxyResolver.ts @@ -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. diff --git a/src/assumeRole.ts b/src/assumeRole.ts deleted file mode 100644 index 735cc9e..0000000 --- a/src/assumeRole.ts +++ /dev/null @@ -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; - 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); -} diff --git a/src/helpers.ts b/src/helpers.ts index 1babe58..b844664 100644 --- a/src/helpers.ts +++ b/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, - outputCredentials?: boolean, - outputEnvCredentials?: boolean, -) { +export function exportCredentials(creds?: Partial) { 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) { + 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 | undefined) { +export function verifyKeys(creds: { AccessKeyId?: string; SecretAccessKey?: string } | undefined) { if (!creds) { return false; } @@ -204,7 +257,7 @@ export async function retryAndBackoff( `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( } } -/* c8 ignore start */ export function errorMessage(error: unknown) { return error instanceof Error ? error.message : String(error); } -export function isDefined(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): 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): Promise { + 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): Promise { + 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)}`); + } +} diff --git a/src/index.ts b/src/index.ts index ff56db7..b0b1114 100644 --- a/src/index.ts +++ b/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 { 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)); }); } diff --git a/src/index.ts.old b/src/index.ts.old new file mode 100644 index 0000000..00414b2 --- /dev/null +++ b/src/index.ts.old @@ -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)); + }); +}