packages/@aws-cdk/toolkit-lib/lib/api/aws-auth/awscli-compatible.ts (154 lines of code) (raw):
import { format } from 'node:util';
import { createCredentialChain, fromEnv, fromIni, fromNodeProviderChain } from '@aws-sdk/credential-providers';
import { MetadataService } from '@aws-sdk/ec2-metadata-service';
import type { NodeHttpHandlerOptions } from '@smithy/node-http-handler';
import { loadSharedConfigFiles } from '@smithy/shared-ini-file-loader';
import type { AwsCredentialIdentityProvider, Logger } from '@smithy/types';
import * as promptly from 'promptly';
import { makeCachingProvider } from './provider-caching';
import { ProxyAgentProvider } from './proxy-agent';
import type { SdkHttpOptions } from './types';
import { IO, type IoHelper } from '../io/private';
import { AuthenticationError } from '../toolkit-error';
const DEFAULT_CONNECTION_TIMEOUT = 10000;
const DEFAULT_TIMEOUT = 300000;
/**
* Behaviors to match AWS CLI
*
* See these links:
*
* https://docs.aws.amazon.com/cli/latest/topic/config-vars.html
* https://docs.aws.amazon.com/cli/latest/userguide/cli-configure-envvars.html
*/
export class AwsCliCompatible {
private readonly ioHelper: IoHelper;
private readonly requestHandler: NodeHttpHandlerOptions;
private readonly logger?: Logger;
public constructor(ioHelper: IoHelper, requestHandler: NodeHttpHandlerOptions, logger?: Logger) {
this.ioHelper = ioHelper;
this.requestHandler = requestHandler;
this.logger = logger;
}
public async baseConfig(profile?: string): Promise<{ credentialProvider: AwsCredentialIdentityProvider; defaultRegion: string }> {
const credentialProvider = await this.credentialChainBuilder({
profile,
logger: this.logger,
});
const defaultRegion = await this.region(profile);
return { credentialProvider, defaultRegion };
}
/**
* Build an AWS CLI-compatible credential chain provider
*
* The credential chain returned by this function is always caching.
*/
public async credentialChainBuilder(
options: CredentialChainOptions = {},
): Promise<AwsCredentialIdentityProvider> {
const clientConfig = {
requestHandler: this.requestHandler,
customUserAgent: 'aws-cdk',
logger: options.logger,
};
// Super hacky solution to https://github.com/aws/aws-cdk/issues/32510, proposed by the SDK team.
//
// Summary of the problem: we were reading the region from the config file and passing it to
// the credential providers. However, in the case of SSO, this makes the credential provider
// use that region to do the SSO flow, which is incorrect. The region that should be used for
// that is the one set in the sso_session section of the config file.
//
// The idea here: the "clientConfig" is for configuring the inner auth client directly,
// and has the highest priority, whereas "parentClientConfig" is the upper data client
// and has lower priority than the sso_region but still higher priority than STS global region.
const parentClientConfig = {
region: await this.region(options.profile),
};
/**
* The previous implementation matched AWS CLI behavior:
*
* If a profile is explicitly set using `--profile`,
* we use that to the exclusion of everything else.
*
* Note: this does not apply to AWS_PROFILE,
* environment credentials still take precedence over AWS_PROFILE
*/
if (options.profile) {
return makeCachingProvider(fromIni({
profile: options.profile,
ignoreCache: true,
mfaCodeProvider: this.tokenCodeFn.bind(this),
clientConfig,
parentClientConfig,
logger: options.logger,
}));
}
const envProfile = process.env.AWS_PROFILE || process.env.AWS_DEFAULT_PROFILE;
/**
* Env AWS - EnvironmentCredentials with string AWS
* Env Amazon - EnvironmentCredentials with string AMAZON
* Profile Credentials - PatchedSharedIniFileCredentials with implicit profile, credentials file, http options, and token fn
* SSO with implicit profile only
* SharedIniFileCredentials with implicit profile and preferStaticCredentials true (profile with source_profile)
* Shared Credential file that points to Environment Credentials with AWS prefix
* Shared Credential file that points to EC2 Metadata
* Shared Credential file that points to ECS Credentials
* SSO Credentials - SsoCredentials with implicit profile and http options
* ProcessCredentials with implicit profile
* ECS Credentials - ECSCredentials with no input OR Web Identity - TokenFileWebIdentityCredentials with no input OR EC2 Metadata - EC2MetadataCredentials with no input
*
* These translate to:
* fromEnv()
* fromSSO()/fromIni()
* fromProcess()
* fromContainerMetadata()
* fromTokenFile()
* fromInstanceMetadata()
*
* The NodeProviderChain is already cached.
*/
const nodeProviderChain = fromNodeProviderChain({
profile: envProfile,
clientConfig,
parentClientConfig,
logger: options.logger,
mfaCodeProvider: this.tokenCodeFn.bind(this),
ignoreCache: true,
});
return shouldPrioritizeEnv()
? createCredentialChain(fromEnv(), nodeProviderChain).expireAfter(60 * 60_000)
: nodeProviderChain;
}
/**
* Attempts to get the region from a number of sources and falls back to us-east-1 if no region can be found,
* as is done in the AWS CLI.
*
* The order of priority is the following:
*
* 1. Environment variables specifying region, with both an AWS prefix and AMAZON prefix
* to maintain backwards compatibility, and without `DEFAULT` in the name because
* Lambda and CodeBuild set the $AWS_REGION variable.
* 2. Regions listed in the Shared Ini Files - First checking for the profile provided
* and then checking for the default profile.
* 3. IMDS instance identity region from the Metadata Service.
* 4. us-east-1
*/
public async region(maybeProfile?: string): Promise<string> {
const defaultRegion = 'us-east-1';
const profile = maybeProfile || process.env.AWS_PROFILE || process.env.AWS_DEFAULT_PROFILE || 'default';
const region =
process.env.AWS_REGION ||
process.env.AMAZON_REGION ||
process.env.AWS_DEFAULT_REGION ||
process.env.AMAZON_DEFAULT_REGION ||
(await this.getRegionFromIni(profile)) ||
(await this.regionFromMetadataService());
if (!region) {
const usedProfile = !profile ? '' : ` (profile: "${profile}")`;
await this.ioHelper.notify(IO.DEFAULT_SDK_DEBUG.msg(
`Unable to determine AWS region from environment or AWS configuration${usedProfile}, defaulting to '${defaultRegion}'`,
));
return defaultRegion;
}
return region;
}
/**
* The MetadataService class will attempt to fetch the instance identity document from
* IMDSv2 first, and then will attempt v1 as a fallback.
*
* If this fails, we will use us-east-1 as the region so no error should be thrown.
* @returns The region for the instance identity
*/
private async regionFromMetadataService() {
await this.ioHelper.notify(IO.DEFAULT_SDK_DEBUG.msg('Looking up AWS region in the EC2 Instance Metadata Service (IMDS).'));
try {
const metadataService = new MetadataService({
httpOptions: {
timeout: 1000,
},
});
await metadataService.fetchMetadataToken();
const document = await metadataService.request('/latest/dynamic/instance-identity/document', {});
return JSON.parse(document).region;
} catch (e) {
await this.ioHelper.notify(IO.DEFAULT_SDK_DEBUG.msg(`Unable to retrieve AWS region from IMDS: ${e}`));
}
}
/**
* Looks up the region of the provided profile. If no region is present,
* it will attempt to lookup the default region.
* @param profile The profile to use to lookup the region
* @returns The region for the profile or default profile, if present. Otherwise returns undefined.
*/
private async getRegionFromIni(profile: string): Promise<string | undefined> {
const sharedFiles = await loadSharedConfigFiles({ ignoreCache: true });
// Priority:
//
// credentials come before config because aws-cli v1 behaves like that.
//
// 1. profile-region-in-credentials
// 2. profile-region-in-config
// 3. default-region-in-credentials
// 4. default-region-in-config
return this.getRegionFromIniFile(profile, sharedFiles.credentialsFile)
?? this.getRegionFromIniFile(profile, sharedFiles.configFile)
?? this.getRegionFromIniFile('default', sharedFiles.credentialsFile)
?? this.getRegionFromIniFile('default', sharedFiles.configFile);
}
private getRegionFromIniFile(profile: string, data?: any) {
return data?.[profile]?.region;
}
/**
* Ask user for MFA token for given serial
*
* Result is send to callback function for SDK to authorize the request
*/
private async tokenCodeFn(serialArn: string): Promise<string> {
const debugFn = (msg: string, ...args: any[]) => this.ioHelper.notify(IO.DEFAULT_SDK_DEBUG.msg(format(msg, ...args)));
await debugFn('Require MFA token for serial ARN', serialArn);
try {
const token: string = await promptly.prompt(`MFA token for ${serialArn}: `, {
trim: true,
default: '',
});
await debugFn('Successfully got MFA token from user');
return token;
} catch (err: any) {
await debugFn('Failed to get MFA token', err);
const e = new AuthenticationError(`Error fetching MFA token: ${err.message ?? err}`);
e.name = 'SharedIniFileCredentialsProviderFailure';
throw e;
}
}
}
/**
* We used to support both AWS and AMAZON prefixes for these environment variables.
*
* Adding this for backward compatibility.
*/
function shouldPrioritizeEnv() {
const id = process.env.AWS_ACCESS_KEY_ID || process.env.AMAZON_ACCESS_KEY_ID;
const key = process.env.AWS_SECRET_ACCESS_KEY || process.env.AMAZON_SECRET_ACCESS_KEY;
if (!!id && !!key) {
process.env.AWS_ACCESS_KEY_ID = id;
process.env.AWS_SECRET_ACCESS_KEY = key;
const sessionToken = process.env.AWS_SESSION_TOKEN ?? process.env.AMAZON_SESSION_TOKEN;
if (sessionToken) {
process.env.AWS_SESSION_TOKEN = sessionToken;
}
return true;
}
return false;
}
export interface CredentialChainOptions {
readonly profile?: string;
readonly logger?: Logger;
}
export async function makeRequestHandler(ioHelper: IoHelper, options: SdkHttpOptions = {}): Promise<NodeHttpHandlerOptions> {
const agent = await new ProxyAgentProvider(ioHelper).create(options);
return {
connectionTimeout: DEFAULT_CONNECTION_TIMEOUT,
requestTimeout: DEFAULT_TIMEOUT,
httpsAgent: agent,
httpAgent: agent,
};
}