Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,8 @@
"dependencies": {
"@aws-sdk/client-cloudformation": "^3.1034.0",
"@aws-sdk/client-s3": "^3.1034.0",
"@aws-sdk/credential-providers": "^3.975.0",
"@aws-sdk/credential-providers": "^3.1034.0",
"@smithy/core": "^3.23.16",
"@smithy/node-http-handler": "^4.6.1",
"@dagrejs/graphlib": "^3.0.4",
"ajv": "^8.11.0",
Expand Down
7 changes: 6 additions & 1 deletion src/state/S3StateStorage.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ const streamToString = require('../utils/stream-to-string');
const ServerlessError = require('../serverless-error');
const BaseStateStorage = require('./BaseStateStorage');
const normalizeState = require('./normalize-state');
const { buildClientConfig } = require('../utils/aws/config');

const getAwsErrorCode = (error) => error && (error.Code || error.code || error.name);

Expand All @@ -19,8 +20,12 @@ class S3StateStorage extends BaseStateStorage {
this.bucketName = config.bucketName;
this.stateKey = config.stateKey;

// The fallback routes through buildClientConfig so that proxy, CA, timeout, and
// retry configuration apply; credential semantics are preserved (the SDK default
// chain still applies when no credentials are given)
this.s3Client = new S3(
config.clientConfig || { region: this.region, credentials: config.credentials }
config.clientConfig ||
buildClientConfig({ region: this.region, credentials: config.credentials })
);

this.writeRequestQueue = pLimit(1);
Expand Down
7 changes: 3 additions & 4 deletions src/state/utils/get-state-bucket-name.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,16 +23,15 @@ const getCloudFormationClient = (stateConfiguration = {}, context = {}) => {
);
};

const monitorStackCreation = async (stackName, context, stateConfiguration) => {
const client = getCloudFormationClient(stateConfiguration, context);
const monitorStackCreation = async (client, stackName, context) => {
const describeStacksResponse = await client.describeStacks({ StackName: stackName });
const status = describeStacksResponse.Stacks[0].StackStatus;

if (status === 'CREATE_IN_PROGRESS') {
// TODO: REMOVE WHEN REPLACED WITH PROGRESS
context.logVerbose('Stack deployment in progress');
await sleep(2000);
return await monitorStackCreation(stackName, context, stateConfiguration);
return await monitorStackCreation(client, stackName, context);
}

if (status === 'CREATE_COMPLETE') {
Expand Down Expand Up @@ -69,7 +68,7 @@ const ensureRemoteStateBucketStackExists = async (context, stateConfiguration) =
],
});

await monitorStackCreation(COMPOSE_REMOTE_STATE_STACK_NAME, context, stateConfiguration);
await monitorStackCreation(client, COMPOSE_REMOTE_STATE_STACK_NAME, context);
context.output.log('S3 bucket for remote state created successfully');
return bucketName;
};
Expand Down
27 changes: 14 additions & 13 deletions src/utils/aws/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ const { NodeHttpHandler } = require('@smithy/node-http-handler');
* @param {Object|Function} options.credentials - AWS credentials or SDK v3 credential provider
* @param {number} options.maxAttempts - Maximum retry attempts
* @param {string} options.retryMode - Retry mode ('legacy', 'standard', 'adaptive')
* @param {Object} options.requestHandler - Request handler override, used in place of the
* proxy, timeout, and certificate configuration derived from the environment
* @returns {Object} AWS SDK v3 client configuration
*/
function buildClientConfig(options = {}) {
Expand All @@ -35,10 +37,7 @@ function buildClientConfig(options = {}) {
config.requestHandler = requestHandler;
} else {
// Configure HTTP options (proxy, timeout, certificates)
const httpOptions = buildHttpOptions();
if (httpOptions) {
config.requestHandler = new NodeHttpHandler(httpOptions);
}
config.requestHandler = new NodeHttpHandler(buildHttpOptions());
}

return config;
Expand All @@ -59,23 +58,22 @@ function getMaxAttempts() {

/**
* Build HTTP options for AWS SDK v3 clients
* @returns {Object|null} HTTP configuration or null if no special config needed
* @returns {Object} HTTP configuration
*/
function buildHttpOptions() {
const httpOptions = {};

// Configure timeout
// Socket inactivity timeout, matching the AWS SDK v2 default of 120 seconds. A bare
// requestTimeout would be warn-only in @smithy/node-http-handler 4.4.0+.
const timeout = process.env.AWS_CLIENT_TIMEOUT || process.env.aws_client_timeout;
if (timeout) {
httpOptions.requestTimeout = parseInt(timeout, 10);
}
httpOptions.socketTimeout = timeout ? parseInt(timeout, 10) : 120000;

// Configure proxy
const proxy = getProxyUrl();

// Configure custom CA certificates
// Configure HTTPS agent options for proxy and custom CA certificates
const caCerts = getCACertificates();
const agentOptions = {};
const agentOptions = { keepAlive: true };
if (caCerts.length > 0) {
Object.assign(agentOptions, {
rejectUnauthorized: true,
Expand All @@ -84,12 +82,15 @@ function buildHttpOptions() {
}

if (proxy) {
httpOptions.httpsAgent = new HttpsProxyAgent(proxy, agentOptions);
// Assigned for both schemes; https-proxy-agent tunnels plain-http requests too
const proxyAgent = new HttpsProxyAgent(proxy, agentOptions);
httpOptions.httpsAgent = proxyAgent;
httpOptions.httpAgent = proxyAgent;
} else if (caCerts.length > 0) {
httpOptions.httpsAgent = new https.Agent(agentOptions);
}

return httpOptions.httpsAgent || 'requestTimeout' in httpOptions ? httpOptions : null;
return httpOptions;
}

/**
Expand Down
121 changes: 116 additions & 5 deletions src/utils/aws/credentials.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,19 @@ const os = require('os');
const path = require('path');
const readline = require('readline');
const { fromIni, fromNodeProviderChain } = require('@aws-sdk/credential-providers');
const { NodeHttpHandler } = require('@smithy/node-http-handler');
const { buildHttpOptions } = require('./config');
const ServerlessError = require('../../serverless-error');
const { log } = require('../serverless-utils/log');

const defaultConfigProfileSectionRegex = /^profile\s+(?:default|"default"|'default')$/;
// Inner SSO/OIDC/STS clients created during credential resolution inherit the same
// proxy, CA, and timeout configuration as regular clients
let sharedRequestHandler;

function getCredentialsRequestHandler() {
if (!sharedRequestHandler) sharedRequestHandler = new NodeHttpHandler(buildHttpOptions());
return sharedRequestHandler;
}

function hasEnvironmentCredentials(prefix) {
return Boolean(
Expand Down Expand Up @@ -37,11 +48,24 @@ function fromPrefixedEnv(prefix) {
function promptMfaCode(mfaSerial) {
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });

return new Promise((resolve) => {
return new Promise((resolve, reject) => {
let answered = false;
rl.question(`Enter MFA code for ${mfaSerial}: `, (answer) => {
answered = true;
rl.close();
resolve(answer);
});
// Without this, stdin EOF (e.g. in CI) would leave the promise unsettled forever
rl.on('close', () => {
if (!answered) {
reject(
new ServerlessError(
`MFA code required for ${mfaSerial} but no interactive terminal is available`,
'MFA_CODE_UNAVAILABLE'
)
);
}
});
});
}

Expand All @@ -62,11 +86,14 @@ function getSharedConfigFilepath() {
}

function fromProfile(profile) {
maybeWarnIdentityDivergence(profile);
return fromIni({
profile,
filepath: getSharedCredentialsFilepath(),
configFilepath: getSharedConfigFilepath(),
mfaCodeProvider: promptMfaCode,
// Region is deliberately omitted: it would override the profile's sso_region
clientConfig: { requestHandler: getCredentialsRequestHandler() },
});
}

Expand All @@ -90,7 +117,76 @@ function getIniSectionNames(filePath) {
}

function isDefaultConfigProfileSection(sectionName) {
return sectionName === 'default' || defaultConfigProfileSectionRegex.test(sectionName);
return isConfigProfileSection(sectionName, 'default');
}

function isConfigProfileSection(sectionName, profile) {
if (sectionName === profile) return true;
if (!sectionName.startsWith('profile')) return false;
const rest = sectionName.slice('profile'.length);
if (!/^\s/.test(rest)) return false;
const name = rest.trim();
return name === profile || name === `"${profile}"` || name === `'${profile}'`;
}

function doesProfileExist(profile) {
if (getIniSectionNames(getSharedCredentialsFilepath()).has(profile)) return true;

for (const sectionName of getIniSectionNames(getSharedConfigFilepath())) {
if (isConfigProfileSection(sectionName, profile)) return true;
}

return false;
}

function doesIniSectionHaveKey(filePath, sectionMatcher, keyName) {
try {
const contents = fs.readFileSync(filePath, 'utf8');
let inSection = false;

for (const line of contents.split(/\r?\n/)) {
const trimmedLine = line.split(/(^|\s)[;#]/)[0].trim();
if (trimmedLine[0] === '[' && trimmedLine[trimmedLine.length - 1] === ']') {
inSection = sectionMatcher(trimmedLine.slice(1, -1));
} else if (inSection) {
const equalsIndex = trimmedLine.indexOf('=');
if (equalsIndex !== -1 && trimmedLine.slice(0, equalsIndex).trim() === keyName) {
return true;
}
}
}

return false;
} catch {
return false;
}
}

const warnedDivergenceProfiles = new Set();

function maybeWarnIdentityDivergence(profile) {
if (warnedDivergenceProfiles.has(profile)) return;
warnedDivergenceProfiles.add(profile);

const hasStaticKeys = doesIniSectionHaveKey(
getSharedCredentialsFilepath(),
(sectionName) => sectionName === profile,
'aws_access_key_id'
);
if (!hasStaticKeys) return;

const hasConfigRoleArn = doesIniSectionHaveKey(
getSharedConfigFilepath(),
(sectionName) => isConfigProfileSection(sectionName, profile),
'role_arn'
);
if (!hasConfigRoleArn) return;

log.warning(
`Profile "${profile}" resolves via the role_arn configured in the AWS config file; ` +
'the static keys in the credentials file are used only as source credentials for ' +
'the AssumeRole call, so commands run under the assumed role identity.'
);
}

function doesImplicitDefaultProfileExist() {
Expand All @@ -114,7 +210,11 @@ function fromImplicitDefaultProfileWithFallback() {
return await profileProvider(providerOptions);
} catch (error) {
if (doesImplicitDefaultProfileExist()) throw error;
if (!fallbackProvider) fallbackProvider = fromNodeProviderChain();
if (!fallbackProvider) {
fallbackProvider = fromNodeProviderChain({
clientConfig: { requestHandler: getCredentialsRequestHandler() },
});
}
return fallbackProvider(providerOptions);
}
};
Expand All @@ -132,7 +232,18 @@ function getCredentialProvider({ profile, stage } = {}) {
}
if (process.env.AWS_PROFILE) return fromProfile(process.env.AWS_PROFILE);
if (hasEnvironmentCredentials('AWS')) return fromPrefixedEnv('AWS');
if (process.env.AWS_DEFAULT_PROFILE) return fromProfile(process.env.AWS_DEFAULT_PROFILE);
if (process.env.AWS_DEFAULT_PROFILE) {
if (doesProfileExist(process.env.AWS_DEFAULT_PROFILE)) {
return fromProfile(process.env.AWS_DEFAULT_PROFILE);
}
log.warning(
`Profile "${process.env.AWS_DEFAULT_PROFILE}" (from AWS_DEFAULT_PROFILE) was not found ` +
'in the AWS credentials or config files; falling back to the default provider chain.'
);
return fromNodeProviderChain({
clientConfig: { requestHandler: getCredentialsRequestHandler() },
});
}

return fromImplicitDefaultProfileWithFallback();
}
Expand Down
47 changes: 45 additions & 2 deletions src/utils/aws/get-credential-provider.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,48 @@
'use strict';

const { getCredentialProvider } = require('./credentials');
const {
memoizeIdentityProvider,
isIdentityExpired,
doesIdentityRequireRefresh,
} = require('@smithy/core');
const { getCredentialProvider, hasEnvironmentCredentials } = require('./credentials');

module.exports = ({ profile, stage } = {}) => getCredentialProvider({ profile, stage });
const memoizedProviders = new Map();

// The key covers every input the resolution chain reads, so environment changes
// produce a fresh provider instead of a stale cache hit
function getCacheKey({ profile, stage } = {}) {
const stageUpper = stage ? stage.toUpperCase() : null;

return JSON.stringify({
profile: profile || null,
stage: stage || null,
awsProfile: process.env.AWS_PROFILE || null,
awsDefaultProfile: process.env.AWS_DEFAULT_PROFILE || null,
stageProfile: stageUpper ? process.env[`AWS_${stageUpper}_PROFILE`] || null : null,
hasStageEnvironmentCredentials: stageUpper
? hasEnvironmentCredentials(`AWS_${stageUpper}`)
: false,
hasEnvironmentCredentials: hasEnvironmentCredentials('AWS'),
sharedCredentialsFile: process.env.AWS_SHARED_CREDENTIALS_FILE || null,
sharedConfigFile: process.env.AWS_CONFIG_FILE || null,
});
}

module.exports = ({ profile, stage } = {}) => {
const cacheKey = getCacheKey({ profile, stage });

if (!memoizedProviders.has(cacheKey)) {
// Memoize process-wide; the memoized flag stops each client adding its own memoizer
// and re-resolving (repeated MFA prompts, AssumeRole, credential_process)
const memoized = memoizeIdentityProvider(
getCredentialProvider({ profile, stage }),
isIdentityExpired,
doesIdentityRequireRefresh
);
memoized.memoized = true;
memoizedProviders.set(cacheKey, memoized);
}

return memoizedProviders.get(cacheKey);
};
18 changes: 18 additions & 0 deletions test/unit/src/state/S3StateStorage.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,24 @@ describe('test/unit/src/state/S3StateStorage.test.js', () => {
retryMode: 'standard',
});
});

it('builds transport-aware client config when no client config is given', () => {
const S3 = sinon.stub().returns({});
const S3StateStorageWithStubbedClient = proxyquire
.noCallThru()
.load('../../../../src/state/S3StateStorage', {
'@aws-sdk/client-s3': { S3 },
});

new S3StateStorageWithStubbedClient({ bucketName, stateKey, region: 'eu-central-1' });

expect(S3).to.have.been.calledOnce;
const config = S3.firstCall.args[0];
expect(config.region).to.equal('eu-central-1');
expect(config.requestHandler).to.exist;
expect(config.maxAttempts).to.be.a('number');
expect(config).to.not.have.property('credentials');
});
});

it('serializes concurrent state writes', async () => {
Expand Down
Loading
Loading