diff --git a/lib/aws.js b/lib/aws.js index 288ac58972..0ed88f0b7e 100644 --- a/lib/aws.js +++ b/lib/aws.js @@ -15,16 +15,25 @@ require('./services'); // Load custom credential providers require('./credentials/ec2_metadata_credentials'); +require('./credentials/ecs_credentials'); require('./credentials/environment_credentials'); require('./credentials/file_system_credentials'); require('./credentials/shared_ini_file_credentials'); // Setup default chain providers +// If this changes, please update documentation for +// AWS.CredentialProviderChain.defaultProviders in +// credentials/credential_provider_chain.js AWS.CredentialProviderChain.defaultProviders = [ function () { return new AWS.EnvironmentCredentials('AWS'); }, function () { return new AWS.EnvironmentCredentials('AMAZON'); }, function () { return new AWS.SharedIniFileCredentials(); }, - function () { return new AWS.EC2MetadataCredentials(); } + function () { + if (AWS.ECSCredentials.prototype.getECSRelativeUri() !== undefined) { + return new AWS.ECSCredentials(); + } + return new AWS.EC2MetadataCredentials(); + } ]; // Update configuration keys diff --git a/lib/credentials/credential_provider_chain.js b/lib/credentials/credential_provider_chain.js index 604d11b290..79bed3d060 100644 --- a/lib/credentials/credential_provider_chain.js +++ b/lib/credentials/credential_provider_chain.js @@ -123,7 +123,12 @@ AWS.CredentialProviderChain = AWS.util.inherit(AWS.Credentials, { * function () { return new AWS.EnvironmentCredentials('AWS'); }, * function () { return new AWS.EnvironmentCredentials('AMAZON'); }, * function () { return new AWS.SharedIniFileCredentials(); }, - * function () { return new AWS.EC2MetadataCredentials(); } + * function () { + * // if AWS_CONTAINER_CREDENTIALS_RELATIVE_URI is set + * return new AWS.ECSCredentials(); + * // else + * return new AWS.EC2MetadataCredentials(); + * } * ] * ``` */ diff --git a/lib/credentials/ecs_credentials.js b/lib/credentials/ecs_credentials.js new file mode 100644 index 0000000000..38df3f7557 --- /dev/null +++ b/lib/credentials/ecs_credentials.js @@ -0,0 +1,142 @@ +var AWS = require('../core'); + +/** + * Represents credentials received from relative URI specified in the ECS container. + * + * This class will request refreshable credentials from the relative URI + * specified by the AWS_CONTAINER_CREDENTIALS_RELATIVE_URI environment variable + * in the container. If valid credentials are returned in the response, these + * will be used with zero configuration. + * + * This credentials class will timeout after 1 second of inactivity by default. + * If your requests to the relative URI are timing out, you can increase + * the value by configuring them directly: + * + * ```javascript + * AWS.config.credentials = new AWS.ECSCredentials({ + * httpOptions: { timeout: 5000 } // 5 second timeout + * }); + * ``` + * + * @!macro nobrowser + */ +AWS.ECSCredentials = AWS.util.inherit(AWS.Credentials, { + constructor: function ECSCredentials(options) { + AWS.Credentials.call(this); + options = options ? AWS.util.copy(options) : {}; + if (!options.httpOptions) options.httpOptions = {}; + options.httpOptions = AWS.util.merge( + this.httpOptions, options.httpOptions); + AWS.util.update(this, options); + }, + + /** + * @api private + */ + httpOptions: { timeout: 1000 }, + + /** + * @api private + */ + host: '169.254.170.2', + + /** + * Sets the name of the ECS environment variable to check for relative URI + * If changed, please change the name in the documentation for defaultProvider + * in credential_provider_chain.js and in all tests in test/credentials.spec.coffee + * + * @api private + */ + environmentVar: 'AWS_CONTAINER_CREDENTIALS_RELATIVE_URI', + + /** + * @api private + */ + getECSRelativeUri: function getECSRelativeUri() { + if (process && process.env) return process.env[this.environmentVar]; + }, + + /** + * @api private + */ + credsFormatIsValid: function credsFormatIsValid(credData) { + return (!!credData.AccessKeyId && !!credData.SecretAccessKey && + !!credData.Token && !!credData.Expiration); + }, + + /** + * @api private + */ + request: function request(path, callback) { + path = path || '/'; + + var data = ''; + var http = AWS.HttpClient.getInstance(); + var httpRequest = new AWS.HttpRequest('http://' + this.host + path); + httpRequest.method = 'GET'; + httpRequest.headers.Accept = 'application/json'; + var httpOptions = this.httpOptions; + + process.nextTick(function() { + http.handleRequest(httpRequest, httpOptions, function(httpResponse) { + httpResponse.on('data', function(chunk) { data += chunk.toString(); }); + httpResponse.on('end', function() { callback(null, data); }); + }, callback); + }); + }, + + /** + * Loads the credentials from the relative URI specified by container + * + * @callback callback function(err) + * Called when the request to the relative URI responds (or fails). When + * this callback is called with no error, it means that the credentials + * information has been loaded into the object (as the `accessKeyId`, + * `secretAccessKey`, `sessionToken`, and `expireTime` properties). + * @param err [Error] if an error occurred, this value will be filled + * @see get + */ + refresh: function refresh(callback) { + var self = this; + if (!callback) callback = function(err) { if (err) throw err; }; + + if (process === undefined) { + callback(AWS.util.error( + new Error('No process info available'), + { code: 'ECSCredentialsProviderFailure' } + )); + return; + } + var relativeUri = this.getECSRelativeUri(); + if (relativeUri === undefined) { + callback(AWS.util.error( + new Error('Variable ' + this.environmentVar + ' not set.'), + { code: 'ECSCredentialsProviderFailure' } + )); + return; + } + + this.request(relativeUri, function(err, data) { + if (!err) { + try { + var creds = JSON.parse(data); + if (self.credsFormatIsValid(creds)) { + self.expired = false; + self.accessKeyId = creds.AccessKeyId; + self.secretAccessKey = creds.SecretAccessKey; + self.sessionToken = creds.Token; + self.expireTime = new Date(creds.Expiration); + } else { + throw AWS.util.error( + new Error('Response data is not in valid format'), + { code: 'ECSCredentialsProviderFailure' } + ); + } + } catch (dataError) { + err = dataError; + } + } + callback(err); + }); + } +}); diff --git a/lib/credentials/environment_credentials.js b/lib/credentials/environment_credentials.js index dc4d9ea749..b406518d2c 100644 --- a/lib/credentials/environment_credentials.js +++ b/lib/credentials/environment_credentials.js @@ -59,8 +59,11 @@ AWS.EnvironmentCredentials = AWS.util.inherit(AWS.Credentials, { refresh: function refresh(callback) { if (!callback) callback = function(err) { if (err) throw err; }; - if (process === undefined) { - callback(new Error('No process info available')); + if (!process || !process.env) { + callback(AWS.util.error( + new Error('No process info or environment variables available'), + { code: 'EnvironmentCredentialsProviderFailure' } + )); return; } @@ -72,7 +75,10 @@ AWS.EnvironmentCredentials = AWS.util.inherit(AWS.Credentials, { if (this.envPrefix) prefix = this.envPrefix + '_'; values[i] = process.env[prefix + keys[i]]; if (!values[i] && keys[i] !== 'SESSION_TOKEN') { - callback(new Error('Variable ' + prefix + keys[i] + ' not set.')); + callback(AWS.util.error( + new Error('Variable ' + prefix + keys[i] + ' not set.'), + { code: 'EnvironmentCredentialsProviderFailure' } + )); return; } } diff --git a/lib/credentials/file_system_credentials.js b/lib/credentials/file_system_credentials.js index 8d5d0de95d..f6d6fc138e 100644 --- a/lib/credentials/file_system_credentials.js +++ b/lib/credentials/file_system_credentials.js @@ -53,7 +53,10 @@ AWS.FileSystemCredentials = AWS.util.inherit(AWS.Credentials, { var creds = JSON.parse(AWS.util.readFileSync(this.filename)); AWS.Credentials.call(this, creds); if (!this.accessKeyId || !this.secretAccessKey) { - throw new Error('Credentials not set in ' + this.filename); + throw AWS.util.error( + new Error('Credentials not set in ' + this.filename), + { code: 'FileSystemCredentialsProviderFailure' } + ); } this.expired = false; callback(); diff --git a/lib/credentials/shared_ini_file_credentials.js b/lib/credentials/shared_ini_file_credentials.js index 45dae1bd6a..9393990e33 100644 --- a/lib/credentials/shared_ini_file_credentials.js +++ b/lib/credentials/shared_ini_file_credentials.js @@ -74,8 +74,10 @@ AWS.SharedIniFileCredentials = AWS.util.inherit(AWS.Credentials, { var profile = creds[this.profile]; if (typeof profile !== 'object') { - throw new Error('Profile ' + this.profile + ' not found in ' + - this.filename); + throw AWS.util.error( + new Error('Profile ' + this.profile + ' not found in ' + this.filename), + { code: 'SharedIniFileCredentialsProviderFailure' } + ); } if (profile['role_arn']) { @@ -88,8 +90,11 @@ AWS.SharedIniFileCredentials = AWS.util.inherit(AWS.Credentials, { this.sessionToken = profile['aws_session_token']; if (!this.accessKeyId || !this.secretAccessKey) { - throw new Error('Credentials not set in ' + this.filename + - ' using profile ' + this.profile); + throw AWS.util.error( + new Error('Credentials not set in ' + this.filename + + ' using profile ' + this.profile), + { code: 'SharedIniFileCredentialsProviderFailure' } + ); } this.expired = false; callback(); @@ -103,9 +108,12 @@ AWS.SharedIniFileCredentials = AWS.util.inherit(AWS.Credentials, { */ loadRoleProfile: function loadRoleProfile(creds, roleProfile, callback) { if (this.disableAssumeRole) { - throw new Error('Role assumption profiles are disabled. ' + - 'Failed to load profile ' + this.profile + ' from ' + - this.filename); + throw AWS.util.error( + new Error('Role assumption profiles are disabled. ' + + 'Failed to load profile ' + this.profile + ' from ' + + this.filename), + { code: 'SharedIniFileCredentialsProviderFailure' } + ); } var self = this; @@ -115,16 +123,22 @@ AWS.SharedIniFileCredentials = AWS.util.inherit(AWS.Credentials, { var sourceProfileName = roleProfile['source_profile']; if (!sourceProfileName) { - throw new Error('source_profile is not set in ' + this.filename + - ' using profile ' + this.profile); + throw AWS.util.error( + new Error('source_profile is not set in ' + this.filename + + ' using profile ' + this.profile), + { code: 'SharedIniFileCredentialsProviderFailure' } + ); } var sourceProfile = creds[sourceProfileName]; if (typeof sourceProfile !== 'object') { - throw new Error('source_profile ' + sourceProfileName + ' set in ' + - this.filename + ' using profile ' + this.profile + - ' does not exist') + throw AWS.util.error( + new Error('source_profile ' + sourceProfileName + ' set in ' + + this.filename + ' using profile ' + this.profile + + ' does not exist'), + { code: 'SharedIniFileCredentialsProviderFailure' } + ); } var sourceCredentials = { @@ -134,9 +148,12 @@ AWS.SharedIniFileCredentials = AWS.util.inherit(AWS.Credentials, { }; if (!sourceCredentials.accessKeyId || !sourceCredentials.secretAccessKey) { - throw new Error('Credentials not set in source_profile ' + - sourceProfileName + ' set in ' + this.filename + - ' using profile ' + this.profile); + throw AWS.util.error( + new Error('Credentials not set in source_profile ' + + sourceProfileName + ' set in ' + this.filename + + ' using profile ' + this.profile), + { code: 'SharedIniFileCredentialsProviderFailure' } + ); } var sts = new AWS.STS({ @@ -176,7 +193,9 @@ AWS.SharedIniFileCredentials = AWS.util.inherit(AWS.Credentials, { (env.HOMEPATH ? ((env.HOMEDRIVE || 'C:/') + env.HOMEPATH) : null); if (!home) { throw AWS.util.error( - new Error('Cannot load credentials, HOME path not set')); + new Error('Cannot load credentials, HOME path not set'), + { code: 'SharedIniFileCredentialsProviderFailure' } + ); } this.filename = path.join(home, '.aws', 'credentials'); diff --git a/test/credentials.spec.coffee b/test/credentials.spec.coffee index aa3dbba308..8356c39239 100644 --- a/test/credentials.spec.coffee +++ b/test/credentials.spec.coffee @@ -425,6 +425,100 @@ if AWS.util.isNode() creds.refresh -> expect(spy.calls.length).to.equal(4) + describe 'AWS.ECSCredentials', -> + creds = null + + beforeEach -> + creds = new AWS.ECSCredentials(host: 'host') + process.env = {} + + afterEach -> + process.env = {} + + mockEndpoint = (expireTime) -> + helpers.spyOn(creds, 'request').andCallFake (path, cb) -> + cb null, + JSON.stringify({ + AccessKeyId: 'KEY' + SecretAccessKey: 'SECRET' + Token: 'TOKEN' + Expiration: expireTime.toISOString() + }) + + describe 'constructor', -> + it 'allows passing of options', -> + expect(creds.host).to.equal('host') + + it 'does not modify options object', -> + opts = {} + creds = new AWS.ECSCredentials(opts) + expect(opts).to.eql({}) + + it 'allows setting timeout', -> + opts = httpOptions: timeout: 5000 + creds = new AWS.ECSCredentials(opts) + expect(creds.httpOptions.timeout).to.equal(5000) + + describe 'getECSRelativeUri', -> + it 'returns undefined when process is not available', -> + process_copy = process + process = undefined + expect(creds.getECSRelativeUri()).to.equal(undefined) + process = process_copy + + it 'returns undefined when relative URI environment variable not set', -> + expect(creds.getECSRelativeUri()).to.equal(undefined) + + it 'returns relative URI when environment variable is set', -> + process.env['AWS_CONTAINER_CREDENTIALS_RELATIVE_URI'] = '/path' + expect(creds.getECSRelativeUri()).to.equal('/path') + + it 'returns relative URI from prototype when environment variable is set', -> + process.env['AWS_CONTAINER_CREDENTIALS_RELATIVE_URI'] = '/path' + expect(AWS.ECSCredentials.prototype.getECSRelativeUri()).to.equal('/path') + + describe 'credsFormatIsValid', -> + it 'returns false when data is missing required property', -> + responseData = {AccessKeyId: 'KEY', SecretAccessKey: 'SECRET', Token: 'TOKEN'} + expect(creds.credsFormatIsValid(responseData)).to.be.false + + it 'returns true when data has all required properties', -> + responseData = { + AccessKeyId: 'KEY', + SecretAccessKey: 'SECRET', + Token: 'TOKEN', + Expiration: (new Date(0)).toISOString() + } + expect(creds.credsFormatIsValid(responseData)).to.be.true + + describe 'needsRefresh', -> + it 'can be expired based on expire time from URI endpoint', -> + process.env['AWS_CONTAINER_CREDENTIALS_RELATIVE_URI'] = '/path' + spy = mockEndpoint(new Date(0)) + creds.refresh(->) + expect(spy.calls.length).to.equal(1) + expect(creds.needsRefresh()).to.equal(true) + + describe 'refresh', -> + it 'loads credentials from specified relative URI', -> + callbackErr = null + process.env['AWS_CONTAINER_CREDENTIALS_RELATIVE_URI'] = '/path' + spy = mockEndpoint(new Date(AWS.util.date.getDate().getTime() + 100000)) + creds.refresh((err) -> callbackErr = err) + expect(spy.calls.length).to.equal(1) + expect(callbackErr).to.be.null + expect(creds.accessKeyId).to.equal('KEY') + expect(creds.secretAccessKey).to.equal('SECRET') + expect(creds.sessionToken).to.equal('TOKEN') + expect(creds.needsRefresh()).to.equal(false) + + it 'passes an error to the callback when environment variable not set', -> + callbackErr = null + spy = mockEndpoint(new Date(AWS.util.date.getDate().getTime() + 100000)) + creds.refresh((err) -> callbackErr = err) + expect(spy.calls.length).to.equal(0) + expect(callbackErr).to.not.be.null + describe 'AWS.TemporaryCredentials', -> creds = null