diff --git a/.changes/next-release/feature-Credentials-1a22eb00.json b/.changes/next-release/feature-Credentials-1a22eb00.json new file mode 100644 index 0000000000..ba3a405063 --- /dev/null +++ b/.changes/next-release/feature-Credentials-1a22eb00.json @@ -0,0 +1,5 @@ +{ + "type": "feature", + "category": "Credentials", + "description": "enables use of credentials_process for sourcing credentials from an external process https://docs.aws.amazon.com/cli/latest/topic/config-vars.html#sourcing-credentials-from-external-processes" +} \ No newline at end of file diff --git a/lib/core.d.ts b/lib/core.d.ts index 2f0e3aa20c..cb787be8c6 100644 --- a/lib/core.d.ts +++ b/lib/core.d.ts @@ -10,6 +10,7 @@ export {EnvironmentCredentials} from './credentials/environment_credentials'; export {FileSystemCredentials} from './credentials/file_system_credentials'; export {SAMLCredentials} from './credentials/saml_credentials'; export {SharedIniFileCredentials} from './credentials/shared_ini_file_credentials'; +export {ProcessCredentials} from './credentials/process_credentials'; export {TemporaryCredentials} from './credentials/temporary_credentials'; export {ChainableTemporaryCredentials} from './credentials/chainable_temporary_credentials'; export {WebIdentityCredentials} from './credentials/web_identity_credentials'; diff --git a/lib/credentials/credential_provider_chain.js b/lib/credentials/credential_provider_chain.js index 2f50ff771f..9e2c0d7d59 100644 --- a/lib/credentials/credential_provider_chain.js +++ b/lib/credentials/credential_provider_chain.js @@ -153,12 +153,9 @@ 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 () { - * // if AWS_CONTAINER_CREDENTIALS_RELATIVE_URI is set - * return new AWS.ECSCredentials(); - * // else - * return new AWS.EC2MetadataCredentials(); - * } + * function () { return new AWS.ECSCredentials(); }, + * function () { return new AWS.ProcessCredentials(); }, + * function () { return new AWS.EC2MetadataCredentials() } * ] * ``` */ diff --git a/lib/credentials/process_credentials.d.ts b/lib/credentials/process_credentials.d.ts new file mode 100644 index 0000000000..3b6c0231d7 --- /dev/null +++ b/lib/credentials/process_credentials.d.ts @@ -0,0 +1,14 @@ +import {Credentials} from '../credentials'; +import {HTTPOptions} from '../config'; +export class ProcessCredentials extends Credentials { + /** + * Creates a new ProcessCredentials object. + */ + constructor(options?: ProcessCredentialsOptions); +} + +interface ProcessCredentialsOptions { + profile?: string + filename?: string + httpOptions?: HTTPOptions +} diff --git a/lib/credentials/process_credentials.js b/lib/credentials/process_credentials.js new file mode 100644 index 0000000000..fc7d893ddc --- /dev/null +++ b/lib/credentials/process_credentials.js @@ -0,0 +1,182 @@ +var AWS = require('../core'); +var proc = require('child_process'); +var iniLoader = AWS.util.iniLoader; + +/** + * Represents credentials loaded from shared credentials file + * (defaulting to ~/.aws/credentials or defined by the + * `AWS_SHARED_CREDENTIALS_FILE` environment variable). + * + * ## Using process credentials + * + * The credentials file can specify a credential provider that executes + * a given process and attempts to read its stdout to recieve a JSON payload + * containing the credentials: + * + * [default] + * credential_process = /usr/bin/credential_proc + * + * Automatically handles refreshing credentials if an Expiration time is + * provided in the credentials payload. Credentials supplied in the same profile + * will take precedence over the credential_process. + * + * Sourcing credentials from an external process can potentially be dangerous, + * so proceed with caution. Other credential providers should be preferred if + * at all possible. If using this option, you should make sure that the shared + * credentials file is as locked down as possible using security best practices + * for your operating system. + * + * ## Using custom profiles + * + * The SDK supports loading credentials for separate profiles. This can be done + * in two ways: + * + * 1. Set the `AWS_PROFILE` environment variable in your process prior to + * loading the SDK. + * 2. Directly load the AWS.ProcessCredentials provider: + * + * ```javascript + * var creds = new AWS.ProcessCredentials({profile: 'myprofile'}); + * AWS.config.credentials = creds; + * ``` + * + * @!macro nobrowser + */ +AWS.ProcessCredentials = AWS.util.inherit(AWS.Credentials, { + /** + * Creates a new ProcessCredentials object. + * + * @param options [map] a set of options + * @option options profile [String] (AWS_PROFILE env var or 'default') + * the name of the profile to load. + * @option options filename [String] ('~/.aws/credentials' or defined by + * AWS_SHARED_CREDENTIALS_FILE process env var) + * the filename to use when loading credentials. + * @option options callback [Function] (err) Credentials are eagerly loaded + * by the constructor. When the callback is called with no error, the + * credentials have been loaded successfully. + */ + constructor: function ProcessCredentials(options) { + AWS.Credentials.call(this); + + options = options || {}; + + this.filename = options.filename; + this.profile = options.profile || process.env.AWS_PROFILE || AWS.util.defaultProfile; + this.get(options.callback || AWS.util.fn.noop); + }, + + /** + * @api private + */ + load: function load(callback) { + var self = this; + try { + var profiles = {}; + var profilesFromConfig = {}; + if (process.env[AWS.util.configOptInEnv]) { + var profilesFromConfig = iniLoader.loadFrom({ + isConfig: true, + filename: process.env[AWS.util.sharedConfigFileEnv] + }); + } + var profilesFromCreds = iniLoader.loadFrom({ + filename: this.filename || + (process.env[AWS.util.configOptInEnv] && process.env[AWS.util.sharedCredentialsFileEnv]) + }); + for (var i = 0, profileNames = Object.keys(profilesFromCreds); i < profileNames.length; i++) { + profiles[profileNames[i]] = profilesFromCreds[profileNames[i]]; + } + // load after profilesFromCreds to prefer profilesFromConfig + for (var i = 0, profileNames = Object.keys(profilesFromConfig); i < profileNames.length; i++) { + profiles[profileNames[i]] = profilesFromConfig[profileNames[i]]; + } + var profile = profiles[this.profile] || {}; + + if (Object.keys(profile).length === 0) { + throw AWS.util.error( + new Error('Profile ' + this.profile + ' not found'), + { code: 'ProcessCredentialsProviderFailure' } + ); + } + + if (profile['credential_process']) { + this.loadViaCredentialProcess(profile, function(err, data) { + if (err) { + callback(err, null); + } else { + self.expired = false; + self.accessKeyId = data.AccessKeyId; + self.secretAccessKey = data.SecretAccessKey; + self.sessionToken = data.SessionToken; + if (data.Expiration) { + self.expireTime = new Date(data.Expiration); + } + callback(null); + } + }); + } else { + throw AWS.util.error( + new Error('Profile ' + this.profile + ' did not include credential process'), + { code: 'ProcessCredentialsProviderFailure' } + ); + } + } catch (err) { + callback(err); + } + }, + + /** + * Executes the credential_process and retrieves + * credentials from the output + * @api private + * @param profile [map] credentials profile + * @throws ProcessCredentialsProviderFailure + */ + loadViaCredentialProcess: function loadViaCredentialProcess(profile, callback) { + proc.exec(profile['credential_process'], function(err, stdOut, stdErr) { + if (err) { + callback(AWS.util.error( + new Error('credential_process returned error'), + { code: 'ProcessCredentialsProviderFailure'} + ), null); + } else { + try { + var credData = JSON.parse(stdOut); + if (credData.Expiration) { + var currentTime = AWS.util.date.getDate(); + var expireTime = new Date(credData.Expiration); + if (expireTime < currentTime) { + throw Error('credential_process returned expired credentials'); + } + } + + if (credData.Version !== 1) { + throw Error('credential_process does not return Version == 1'); + } + callback(null, credData); + } catch (err) { + callback(AWS.util.error( + new Error(err.message), + { code: 'ProcessCredentialsProviderFailure'} + ), null); + } + } + }); + }, + + /** + * Loads the credentials from the credential process + * + * @callback callback function(err) + * Called after the credential process has been executed. When this + * callback is called with no error, it means that the credentials + * information has been loaded into the object (as the `accessKeyId`, + * `secretAccessKey`, and `sessionToken` properties). + * @param err [Error] if an error occurred, this value will be filled + * @see get + */ + refresh: function refresh(callback) { + this.coalesceRefresh(callback || AWS.util.fn.callback); + } +}); diff --git a/lib/credentials/shared_ini_file_credentials.js b/lib/credentials/shared_ini_file_credentials.js index 55a2ce959f..1eb3807340 100644 --- a/lib/credentials/shared_ini_file_credentials.js +++ b/lib/credentials/shared_ini_file_credentials.js @@ -72,12 +72,6 @@ AWS.SharedIniFileCredentials = AWS.util.inherit(AWS.Credentials, { * * **timeout** [Integer] — Sets the socket to timeout after timeout * milliseconds of inactivity on the socket. Defaults to two minutes * (120000). - * * **xhrAsync** [Boolean] — Whether the SDK will send asynchronous - * HTTP requests. Used in the browser environment only. Set to false to - * send requests synchronously. Defaults to true (async on). - * * **xhrWithCredentials** [Boolean] — Sets the "withCredentials" - * property of an XMLHttpRequest object. Used in the browser environment - * only. Defaults to false. */ constructor: function SharedIniFileCredentials(options) { AWS.Credentials.call(this); diff --git a/lib/node_loader.js b/lib/node_loader.js index 15c180dee2..91950419e8 100644 --- a/lib/node_loader.js +++ b/lib/node_loader.js @@ -34,6 +34,7 @@ require('./credentials/chainable_temporary_credentials'); require('./credentials/web_identity_credentials'); require('./credentials/cognito_identity_credentials'); require('./credentials/saml_credentials'); +require('./credentials/process_credentials'); // Load the xml2js XML parser AWS.XML.Parser = require('./xml/node_parser'); @@ -50,6 +51,7 @@ require('./credentials/ecs_credentials'); require('./credentials/environment_credentials'); require('./credentials/file_system_credentials'); require('./credentials/shared_ini_file_credentials'); +require('./credentials/process_credentials'); // Setup default chain providers // If this changes, please update documentation for @@ -59,12 +61,9 @@ AWS.CredentialProviderChain.defaultProviders = [ function () { return new AWS.EnvironmentCredentials('AWS'); }, function () { return new AWS.EnvironmentCredentials('AMAZON'); }, function () { return new AWS.SharedIniFileCredentials(); }, - function () { - if (AWS.ECSCredentials.prototype.isConfiguredForEcsCredentials()) { - return new AWS.ECSCredentials(); - } - return new AWS.EC2MetadataCredentials(); - } + function () { return new AWS.ECSCredentials(); }, + function () { return new AWS.ProcessCredentials(); }, + function () { return new AWS.EC2MetadataCredentials(); } ]; // Update configuration keys diff --git a/test/credentials.spec.js b/test/credentials.spec.js index 2e2006f575..4d6a326f08 100644 --- a/test/credentials.spec.js +++ b/test/credentials.spec.js @@ -124,6 +124,7 @@ 'RemoteCredentials', 'SAMLCredentials', 'SharedIniFileCredentials', + 'ProcessCredentials', 'WebIdentityCredentials' ], function(credClass) { @@ -468,6 +469,213 @@ }); }); }); + describe('AWS.ProcessCredentials', function() { + var os = require('os'); + var homedir = os.homedir; + var env; + afterEach(function() { + process.env = env; + }); + beforeEach(function() { + env = process.env; + process.env = {}; + delete os.homedir; + }); + afterEach(function() { + iniLoader.clearCachedFiles(); + os.homedir = homedir; + }); + describe('constructor', function() { + beforeEach(function() { + var mock; + mock = '[default]\naws_access_key_id = akid\naws_secret_access_key = secret\naws_session_token = session'; + return helpers.spyOn(AWS.util, 'readFileSync').andReturn(mock); + }); + it('should use os.homedir if available', function() { + helpers.spyOn(os, 'homedir').andReturn('/foo/bar/baz'); + new AWS.ProcessCredentials(); + expect(os.homedir.calls.length).to.equal(1); + expect(AWS.util.readFileSync.calls.length).to.equal(1); + return expect(AWS.util.readFileSync.calls[0]['arguments'][0]).to.match(/[\/\\]foo[\/\\]bar[\/\\]baz[\/\\].aws[\/\\]credentials/); + }); + it('should prefer $HOME to os.homedir', function() { + process.env.HOME = '/home/user'; + helpers.spyOn(os, 'homedir').andReturn(process.env.HOME + '/foo/bar'); + + new AWS.ProcessCredentials(); + expect(os.homedir.calls.length).to.equal(0); + expect(AWS.util.readFileSync.calls.length).to.equal(1); + return expect(AWS.util.readFileSync.calls[0].arguments[0]).to + .match(/[\/\\]home[\/\\]user[\/\\].aws[\/\\]credentials/); + }); + it('passes an error to callback if HOME/HOMEPATH/USERPROFILE are not set', function(done) { + new AWS.ProcessCredentials({ + callback: function (err) { + expect(err).to.be.instanceof(Error); + expect(err.message).to.equal('Cannot load credentials, HOME path not set'); + done(); + } + }); + }); + it('uses HOMEDRIVE\\HOMEPATH if HOME and USERPROFILE are not set', function() { + var creds; + process.env.HOMEDRIVE = 'd:/'; + process.env.HOMEPATH = 'homepath'; + creds = new AWS.ProcessCredentials(); + creds.get(); + expect(AWS.util.readFileSync.calls.length).to.equal(1); + return expect(AWS.util.readFileSync.calls[0]['arguments'][0]).to.match(/d:[\/\\]homepath[\/\\].aws[\/\\]credentials/); + }); + it('uses default HOMEDRIVE of C:/', function() { + var creds; + process.env.HOMEPATH = 'homepath'; + creds = new AWS.ProcessCredentials(); + creds.get(); + expect(AWS.util.readFileSync.calls.length).to.equal(1); + return expect(AWS.util.readFileSync.calls[0]['arguments'][0]).to.match(/C:[\/\\]homepath[\/\\].aws[\/\\]credentials/); + }); + it('uses USERPROFILE if HOME is not set', function() { + var creds; + process.env.USERPROFILE = '/userprofile'; + creds = new AWS.ProcessCredentials(); + creds.get(); + expect(AWS.util.readFileSync.calls.length).to.equal(1); + return expect(AWS.util.readFileSync.calls[0]['arguments'][0]).to.match(/[\/\\]userprofile[\/\\].aws[\/\\]credentials/); + }); + return it('can override filename as a constructor argument', function() { + var creds; + creds = new AWS.ProcessCredentials({ + filename: '/etc/creds' + }); + creds.get(); + expect(AWS.util.readFileSync.calls.length).to.equal(1); + return expect(AWS.util.readFileSync.calls[0]['arguments'][0]).to.equal('/etc/creds'); + }); + }); + describe('loading', function() { + beforeEach(function() { + process.env.HOME = '/home/user'; + var child_process = require('child_process'); + var mockConfig, mockProcess, creds; + mockConfig = '[default]\ncredential_process=federated_cli_mock'; + helpers.spyOn(AWS.util, 'readFileSync').andReturn(mockConfig); + mockProcess = '{"Version": 1,"AccessKeyId": "akid","SecretAccessKey": "secret","SessionToken": "session","Expiration": ""}'; + helpers.spyOn(child_process, 'exec').andCallFake(function (_, cb) { + cb(undefined, mockProcess, undefined); + }); + }); + afterEach(function() { + iniLoader.clearCachedFiles(); + }); + it('loads successfully using default profile', function(done) { + creds = new AWS.ProcessCredentials(); + creds.refresh(function(err) { + expect(creds.accessKeyId).to.equal('akid'); + expect(creds.secretAccessKey).to.equal('secret'); + expect(creds.sessionToken).to.equal('session'); + expect(creds.expireTime).to.be.null; + done(); + }); + }); + it('loads successfully using named profile', function(done) { + mockConfig = '[foo]\ncredential_process=federated_cli_mock'; + helpers.spyOn(AWS.util, 'readFileSync').andReturn(mockConfig); + creds = new AWS.ProcessCredentials({profile: 'foo'}); + creds.refresh(function(err) { + expect(creds.accessKeyId).to.equal('akid'); + expect(creds.secretAccessKey).to.equal('secret'); + expect(creds.sessionToken).to.equal('session'); + expect(creds.expireTime).to.be.null; + done(); + }); + }); + it('throws error if version is not 1', function(done) { + var child_process = require('child_process'); + mockProcess = '{"Version": 2,"AccessKeyId": "xxx","SecretAccessKey": "yyy","SessionToken": "zzz","Expiration": ""}'; + helpers.spyOn(child_process, 'exec').andCallFake(function (_, cb) { + cb(undefined, mockProcess, undefined); + }); + var creds = new AWS.ProcessCredentials(); + creds.refresh(function(err) { + expect(err).to.exist; + expect(err.message).to.match(/^credential_process does not return Version == 1/); + done(); + }); + }); + it('throws error if credentials are expired', function(done) { + var child_process = require('child_process'); + var expired; + expired = AWS.util.date.iso8601(new Date(0)); + mockProcess = '{"Version": 1,"AccessKeyId": "xxx","SecretAccessKey": "yyy","SessionToken": "zzz","Expiration": "' + expired +'"}'; + helpers.spyOn(child_process, 'exec').andCallFake(function (_, cb) { + cb(undefined, mockProcess, undefined); + }); + var creds = new AWS.ProcessCredentials(); + creds.refresh(function(err) { + expect(err.message).to.eql('credential_process returned expired credentials'); + expect(err).to.not.be.null; + done(); + }); + }); + it('thorws error if an error is returned', function(done) { + var child_process = require('child_process'); + var mockErr; + mockErr = 'foo Error'; + helpers.spyOn(child_process, 'exec').andCallFake(function (_, cb) { + cb(mockErr, undefined, undefined); + }); + var creds = new AWS.ProcessCredentials(); + creds.refresh(function(err) { + expect(err.message).to.eql('credential_process returned error'); + expect(creds.accessKeyId).to.be.undefined; + done(); + }); + }); + it('sets expireTime if an expiration is included', function(done) { + var child_process = require('child_process'); + var futureExpiration; + futureExpiration = AWS.util.date.unixTimestamp() + 900; + futureExpiration = AWS.util.date.iso8601(new Date(futureExpiration * 1000)); + mockProcess = '{"Version": 1,"AccessKeyId": "akid","SecretAccessKey": "secret","SessionToken": "session","Expiration": "' + futureExpiration + '"}'; + helpers.spyOn(child_process, 'exec').andCallFake(function (_, cb) { + cb(undefined, mockProcess, undefined); + }); + creds = new AWS.ProcessCredentials(); + creds.refresh(function(err) { + expect(creds.expireTime).to.eql(AWS.util.date.from(futureExpiration)); + done(); + }); + }); + return it('does not set expireTime if expiration is empty', function(done) { + var child_process = require('child_process'); + creds = new AWS.ProcessCredentials({ profile: 'foo' }); + creds.get(); + creds.refresh(function(err) { + expect(creds.expireTime).to.be.null; + done(); + }); + }); + }); + describe('refresh', function() { + var origEnv = process.env; + beforeEach(function() { + process.env = {}; + }); + afterEach(function() { + process.env = origEnv; + iniLoader.clearCachedFiles(); + }); + return it('fails if credentials are not in the file', function(done) { + var mock = ''; + process.env.HOME = '/home/user'; + helpers.spyOn(AWS.util, 'readFileSync').andReturn(mock); + new AWS.ProcessCredentials().refresh(function(err) { + expect(err.message).to.match(/^Profile default not found/); + done(); + }); + }); + }); + }); describe('loadRoleProfile', function() { var env; beforeEach(function() {