/**
DynDB 0.1.0 - (c) 2012 Sergio Alcantara
Provides the base DynDB constructor
@module dyndb
@author Sergio Alcantara
*/
var http = require('http'),
https = require('https'),
_ = require('underscore'),
crypto = require('crypto');
/**
DynDB Constructor.
It accepts the AWS access keys and the AWS region. If no arguments are passed,
it gets the keys and region from the following environment variables:
1. `AWS_ACCESS_KEY_ID`
2. `AWS_SECRET_ACCESS_KEY`
3. `AWS_REGION`
@class DynDB
@constructor
@param {String} [accessKeyID] Your AWS access key ID
@param {String} [secretAccessKey] Your AWS secret access key
@param {String} [region='us-east-1'] The AWS region you would like to use
@param {String} [securityToken] The security token
*/
function DynDB() {
/**
DynamoDB's service name used in the HTTP headers and to create the signature that authenticates every request
@property SERVICE_NAME
@type String
@final
@private
*/
var SERVICE_NAME = 'DynamoDB';
/**
DynamoDB's API version used in the HTTP headers of every request
@property API_VERSION
@type String
@final
@private
*/
var API_VERSION = '20111205';
/**
IP address of the EC2 metadata service.
@property EC2_METADATA_HOST
@type String
@final
@private
*/
var EC2_METADATA_HOST = '169.254.169.254';
/**
Path to the `security-credentials` within the EC2 metadata service.
@property SECURITY_CREDENTIALS_RESOURCE
@type String
@final
@private
*/
var SECURITY_CREDENTIALS_RESOURCE = '/latest/meta-data/iam/security-credentials/';
var CREDENTIALS_EXPIRATION_THRESHOLD = 1000 * 60 * 5; // 5 minutes in milliseconds
/**
AWS region.
@property region
@type String
@private
*/
var region = null;
/**
AWS Access Key ID.
@property accessKey
@type String
@private
*/
var accessKey = null;
/**
AWS Secret Access Key.
@property secretKey
@type String
@private
*/
var secretKey = null;
/**
AWS Security Token, used only when the credentials come from the EC2 metadata service.
@property securityToken
@type String
@private
*/
var securityToken = null;
/**
Expiration date of the current credentials obtained from the EC2 metadata service.
@property credentialsExpiration
@type Date
@private
*/
var credentialsExpiration = null;
/**
Sets the access keys and region to use for every request. If no arguments are passed,
it gets the keys and region from the following environment variables:
1. `AWS_ACCESS_KEY_ID`
2. `AWS_SECRET_ACCESS_KEY`
3. `AWS_REGION`
@method setup
@chainable
@param {String} [accessKeyID] Your AWS access key ID
@param {String} [secretAccessKey] Your AWS secret access key
@param {String} [region='us-east-1'] The AWS region you would like to use
@param {String} [securityToken] The security token
*/
(this.setup = function(accessKeyID, secretAccessKey, awsRegion, awsSecurityToken) {
region = awsRegion || process.env.AWS_REGION || 'us-east-1';
accessKey = accessKeyID || process.env.AWS_ACCESS_KEY_ID;
secretKey = secretAccessKey || process.env.AWS_SECRET_ACCESS_KEY;
securityToken = awsSecurityToken || null;
return this;
}).apply(this, arguments);
/**
Uses [Crypto.Hmac](http://nodejs.org/api/crypto.html#crypto_class_hmac) to calculate the SHA256 HMAC for the given data
@method hmac
@private
@param {String} key The hmac key to be used
@param {String} data The data for which to calculate the HMAC
@param {String} [encoding='binary'] The desired output encoding for the HMAC. Can be `'hex'`, `'binary'` or `'base64'`
@returns {Object} The calculated HMAC value. The type of the return value depends on the given `encoding`
*/
function hmac(key, data, encoding) {
var hmac = crypto.createHmac('sha256', key);
hmac.update(data);
return hmac.digest(encoding || 'binary');
}
/**
Uses [Crypto.Hash](http://nodejs.org/api/crypto.html#crypto_class_hash) to calculate the SHA256 hash for the given data
@method sha256Hash
@private
@param {String} data The data from which to calculate the hash
@param {String} [dataEncoding='binary'] The data's encoding. Can be `'utf8'`, `'ascii'` or `'binary'`
@param {String} [encoding='hex'] The desired output encoding for the hash. Can be `'hex'`, `'binary'` or `'base64'`
@returns {Object} The calculated hash value. The type of the return value depends on the given `encoding`
*/
function sha256Hash(data, dataEncoding, outputEncoding) {
var hash = crypto.createHash('sha256');
hash.update(data, dataEncoding || 'binary');
return hash.digest(outputEncoding || 'hex');
}
/**
Signs a given request. Follows the [AWS signature v4](http://docs.amazonwebservices.com/general/latest/gr/signature-version-4.html)
specs to add the `Authorization` header in `httpOpts`
@method signRequest
@private
@param {Object} httpOpts The http options object that will be used when calling the `https.request()` method
@param {Buffer} body The `Buffer` instance of the request body
*/
function signRequest(httpOpts, body) {
if (securityToken) httpOpts.headers['x-amz-security-token'] = securityToken;
var headers = _.extend({host: httpOpts.host}, httpOpts.headers),
headerNames = _.sortBy(_.keys(headers), function(name) { return name.toLowerCase(); }),
isoDate = headers['x-amz-date'],
dateStr = isoDate.substr(0, 8),
credentialScope = dateStr + '/' + region + '/' + SERVICE_NAME.toLowerCase() + '/aws4_request';
var canonicalRequest = httpOpts.method + '\n' +
encodeURI(httpOpts.path) + '\n' +
'\n' + // Query String
_.map(headerNames, function(name) { return name.toLowerCase() + ':' + ('' + headers[name]).trim() + '\n'; }).join('') + '\n' +
_.map(headerNames, function(name) { return name.toLowerCase(); }).join(';') + '\n' +
sha256Hash(body);
var strToSign = 'AWS4-HMAC-SHA256\n' +
isoDate + '\n' +
credentialScope + '\n' +
sha256Hash(canonicalRequest, 'utf8');
var derivedKey = hmac(hmac(hmac(hmac('AWS4' + secretKey, dateStr), region), SERVICE_NAME.toLowerCase()), 'aws4_request'),
signature = hmac(derivedKey, strToSign, 'hex');
httpOpts.headers.Authorization = 'AWS4-HMAC-SHA256 Credential=' + accessKey + '/' + credentialScope +
',SignedHeaders=' + _.map(headerNames, function(name) { return name.toLowerCase(); }).join(';') +
',Signature=' + signature;
};
/**
Generates an ISO8601 basic date string (`YYYYMMDD'T'HHMMSS'Z'`) for the given date instance
@method basicISODate
@private
@param {Date} [date] A date instance. If no date is given, a `new Date()` is used
@return {String} An ISO8601 basic date string
*/
function basicISODate(date) {
// Can be done with `str.replace(/(-|:|\.\d{3}Z$)/g, '')`, but `substr()` performs better: http://jsperf.com/iso8601-date-basic-format
var str = (date || new Date()).toISOString();
return str.substr(0, 4) + str.substr(5, 2) + str.substr(8, 5) + str.substr(14, 2) + str.substr(17, 2) + 'Z';
}
/**
This is the method that send the actual HTTP requests, caches the response, and executes the callback after the response has been received.
@method httpRequest
@private
@param {Object} options The [HTTP options](http://nodejs.org/api/http.html#http_http_request_options_callback) to use when sending the request.
@param {Buffer|String} body The request's body. Must be a [Buffer](http://nodejs.org/api/buffer.html) or a String.
Set it to `null`, `undefined`, `false`, or `''` to send a request with no body (a `GET` request for instance).
@param {Function} [callback] Callback function to call after the response has been received.
@param {Boolean} [useHttps=false] Set this to true to use HTTPS.
*/
function httpRequest(options, body, callback, useHttps) {
// The `processResponse` callback should only be called once. The [http docs](http://nodejs.org/api/http.html#http_event_close_1)
// say that a 'close' event can be fired after the 'end' event has been fired. To ensure that the `processResponse` callback
// is called only once it is wrapped by `_.once()`
var processResponse = _.once(function(error, responseBody, httpResponse) {
if (!error && httpResponse && httpResponse.statusCode !== 200) error = httpResponse.statusCode;
if (_.isFunction(callback)) callback(error, responseBody, httpResponse);
});
var request = (useHttps === true ? https : http).request(options, function(httpResponse) {
var responseBody = '';
httpResponse.on('data', function(data) {
responseBody += data.toString(); // 'data' can be a String or a Buffer object
}).on('close', function(error) {
processResponse(error, responseBody, httpResponse);
}).on('end', function() {
processResponse(undefined, responseBody, httpResponse);
});
}).on('error', processResponse);
if (!_.isEmpty(body)) request.write(body);
request.end();
}
/**
Underlying method that builds the request before sending it and relays the response to the callback.
@method sendRequest
@private
@param {String} operationName Name of the DynamoDB operation to request.
@param {Object|String} [body='{}'] Body of the request.
@param {Function} [callback] Callback function to call after receiving the response.
@param {Object} [context=httpResponseObject] Context in which the callback should be executed.
Defaults to the underlying HTTP response object.
*/
function sendRequest(operationName, body, callback, context) {
if (_.isFunction(body)) {
context = callback;
callback = body;
body = undefined;
}
body || (body = {});
body = new Buffer(_.isString(body) ? body : JSON.stringify(body));
var httpOpts = {
host: SERVICE_NAME.toLowerCase() + '.' + region + '.amazonaws.com',
port: 443,
path: '/',
method: 'POST',
headers: {
'x-amz-date': basicISODate(),
'x-amz-target': SERVICE_NAME + '_' + API_VERSION + '.' + operationName,
'Content-Type': 'application/x-amz-json-1.0'
}
};
signRequest(httpOpts, body);
httpRequest(httpOpts, body, function(error, responseBody, httpResponse) {
var json;
try {
json = JSON.parse(responseBody);
} catch (parseError) {
if (!error) error = parseError;
}
if (_.isFunction(callback)) callback.call(context || httpResponse, error, json || responseBody);
}, true);
}
/**
Gets the EC2 IAM role name from the metadata service.
@method getEC2IAMRole
@private
@param {Function} callback Callback function that receives the role returned by the metadata service.
*/
function getEC2IAMRole(callback) {
httpRequest({host: EC2_METADATA_HOST, path: SECURITY_CREDENTIALS_RESOURCE}, null, function(error, role, httpResponse) {
callback(error, _.isString(role) ? role.trim() : role, httpResponse);
});
}
/**
Gets the security credentials from the metadata service.
@method getEC2IAMSecurityCredentials
@private
@param {String} role IAM role name.
@param {Function} callback Callback function that receives the parsed credentials object returned by the metadata service.
*/
function getEC2IAMSecurityCredentials(role, callback) {
httpRequest({host: EC2_METADATA_HOST, path: SECURITY_CREDENTIALS_RESOURCE + role}, null, function(error, jsonStr, httpResponse) {
var credentials;
try {
credentials = JSON.parse(jsonStr);
} catch (parseError) {
if (!error) error = parseError;
}
callback(error, credentials, httpResponse);
});
}
/**
Helper function that returns `true` if getting the security credentials from the metadata service is needed or `false` otherwise.
@method needsToLoadEC2IAMRoleCredentials
@private
*/
function needsToLoadEC2IAMRoleCredentials() {
if (!accessKey || !secretKey) return true;
if (credentialsExpiration === null) return false;
return (credentialsExpiration.getTime() - Date.now()) < CREDENTIALS_EXPIRATION_THRESHOLD;
}
/**
Sends a request to Amazon DynamoDB
@method request
@chainable
@param {String} operationName Name of the DynamoDB operation to request. Consult
[DynamoDB documentation](http://docs.amazonwebservices.com/amazondynamodb/latest/developerguide/operationlist.html 'DynamoDB Operations')
for a list of available operations
@param {Object|String} [body='{}'] Body of the request to send. If it's not a string, it is converted into a string using `JSON.stringify()`.
Consult [DynamoDB documentation](http://docs.amazonwebservices.com/amazondynamodb/latest/developerguide/operationlist.html 'DynamoDB Operations')
for details about the body for each type of operation
@param {Function} [callback] Callback function to execute after the response has been received or when an error was triggered during the request.
The callback function should take the following arguments:
@param {Object} callback.error This can be:
1. The error object passed by the underlying http request if the 'error' event was triggered
2. The response's status code, if it's not `200`
3. Exception object thrown by `JSON.parse()`
4. Undefined if there was no error
@param {Object|String} callback.json The parsed JSON response or a string if the response is not a valid JSON string
@param {Object} [context] The `context` in which the callback should be executed, meaning that whenever the callback function is called,
the value of `this`, inside the callback, will be `context`. If no `context` is given, the value of `this` would be the underlying request's
[http.ClientResponse](http://nodejs.org/api/http.html#http_http_clientresponse) object, which contains data like `statusCode` and `headers`
*/
this.request = function(operationName, body, callback, context) {
if (needsToLoadEC2IAMRoleCredentials()) {
var _this = this,
_arguments = arguments;
getEC2IAMRole(function(error, role, httpResponse) {
getEC2IAMSecurityCredentials(role, function(error, credentials, httpResponse) {
if (!credentials || !credentials.AccessKeyId || !credentials.SecretAccessKey) {
throw 'AWS credentials not set. ' +
'Please set AWS credentials manually, using environment variables, or using EC2 IAM roles. ' +
'See documentation for details.';
}
accessKey = credentials.AccessKeyId;
secretKey = credentials.SecretAccessKey;
securityToken = credentials.Token;
credentialsExpiration = new Date(credentials.Expiration);
sendRequest.apply(_this, _arguments);
});
});
} else {
sendRequest.apply(this, arguments);
}
return this;
};
}
module.exports = DynDB;