From ba3b77576305c338b758ed4c82a9023a3b160b66 Mon Sep 17 00:00:00 2001 From: Jonathan Eskew Date: Tue, 13 Jun 2017 16:35:17 -0700 Subject: [PATCH 1/3] Add a credential provider that reads from shared ini files --- packages/credential-provider-ini/.gitignore | 4 + .../credential-provider-ini/__mocks__/fs.ts | 37 + .../credential-provider-ini/__mocks__/os.ts | 10 + .../__tests__/index.ts | 784 ++++++++++++++++++ packages/credential-provider-ini/index.ts | 267 ++++++ packages/credential-provider-ini/package.json | 28 + .../credential-provider-ini/tsconfig.json | 9 + 7 files changed, 1139 insertions(+) create mode 100644 packages/credential-provider-ini/.gitignore create mode 100644 packages/credential-provider-ini/__mocks__/fs.ts create mode 100644 packages/credential-provider-ini/__mocks__/os.ts create mode 100644 packages/credential-provider-ini/__tests__/index.ts create mode 100755 packages/credential-provider-ini/index.ts create mode 100644 packages/credential-provider-ini/package.json create mode 100755 packages/credential-provider-ini/tsconfig.json diff --git a/packages/credential-provider-ini/.gitignore b/packages/credential-provider-ini/.gitignore new file mode 100644 index 000000000000..b5d6f1c7b0fa --- /dev/null +++ b/packages/credential-provider-ini/.gitignore @@ -0,0 +1,4 @@ +/node_modules/ +*.js +*.js.map +*.d.ts diff --git a/packages/credential-provider-ini/__mocks__/fs.ts b/packages/credential-provider-ini/__mocks__/fs.ts new file mode 100644 index 000000000000..e216fdbdb435 --- /dev/null +++ b/packages/credential-provider-ini/__mocks__/fs.ts @@ -0,0 +1,37 @@ +interface FsModule { + __addMatcher(toMatch: string, toReturn: string): void; + __clearMatchers(): void; + readFile: (path: string, encoding: string, cb: Function) => void +} + +const fs: FsModule = jest.genMockFromModule('fs'); +const matchers = new Map(); + +function __addMatcher(toMatch: string, toReturn: string): void { + matchers.set(toMatch, toReturn); +} + +function __clearMatchers(): void { + matchers.clear(); +} + +function readFile( + path: string, + encoding: string, + callback: (err: Error|null, data?: string) => void +): void { + for (let [matcher, data] of matchers.entries()) { + if (matcher === path) { + callback(null, data); + return; + } + } + + callback(new Error('ENOENT: no such file or directory')); +} + +fs.__addMatcher = __addMatcher; +fs.__clearMatchers = __clearMatchers; +fs.readFile = readFile; + +module.exports = fs; diff --git a/packages/credential-provider-ini/__mocks__/os.ts b/packages/credential-provider-ini/__mocks__/os.ts new file mode 100644 index 000000000000..2768b30f83b4 --- /dev/null +++ b/packages/credential-provider-ini/__mocks__/os.ts @@ -0,0 +1,10 @@ +interface OsModule { + homedir: () => string; +} + +const os: OsModule = jest.genMockFromModule('os'); +const path = require('path'); + +os.homedir = () => path.sep + path.join('home', 'user'); + +module.exports = os; diff --git a/packages/credential-provider-ini/__tests__/index.ts b/packages/credential-provider-ini/__tests__/index.ts new file mode 100644 index 000000000000..f57f287c67d9 --- /dev/null +++ b/packages/credential-provider-ini/__tests__/index.ts @@ -0,0 +1,784 @@ +import {CredentialProvider, Credentials} from "@aws/types"; +jest.mock('fs'); +jest.mock('os'); + +const {__addMatcher, __clearMatchers} = require('fs'); +const {homedir} = require('os'); + +import {join, sep} from 'path'; +import { + AssumeRoleParams, + ENV_CONFIG_PATH, + ENV_CREDENTIALS_PATH, + ENV_PROFILE, + fromIni +} from "../"; +import {CredentialError} from '@aws/credential-provider-base'; + +const DEFAULT_CREDS = { + accessKeyId: 'AKIAIOSFODNN7EXAMPLE', + secretAccessKey: 'wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY', + sessionToken: 'sessionToken', +}; + +const FOO_CREDS = { + accessKeyId: 'foo', + secretAccessKey: 'bar', + sessionToken: 'baz', +}; + +const envAtLoadTime: {[key: string]: string} = [ + ENV_CONFIG_PATH, + ENV_CREDENTIALS_PATH, + ENV_PROFILE, + 'HOME', + 'USERPROFILE', + 'HOMEPATH', + 'HOMEDRIVE', +].reduce((envState, varName) => Object.assign( + envState, + {[varName]: process.env[varName]} +), {}); + +beforeEach(() => { + __clearMatchers(); + Object.keys(envAtLoadTime).forEach(envKey => { + delete process.env[envKey]; + }); +}); + +afterAll(() => { + __clearMatchers(); + Object.keys(envAtLoadTime).forEach(envKey => { + process.env[envKey] = envAtLoadTime[envKey]; + }); +}); + +describe('fromIni', () => { + it('should flag a lack of credentials as a non-terminal error', async () => { + await fromIni()().then( + () => { throw new Error('The promise should have been rejected.'); }, + err => { + expect((err as CredentialError).tryNextLink).toBe(true); + } + ); + }); + + describe('shared credentials file', () => { + const SIMPLE_CREDS_FILE = ` +[default] +aws_access_key_id = ${DEFAULT_CREDS.accessKeyId} +aws_secret_access_key = ${DEFAULT_CREDS.secretAccessKey} +aws_session_token = ${DEFAULT_CREDS.sessionToken} + +[foo] +aws_access_key_id = ${FOO_CREDS.accessKeyId} +aws_secret_access_key = ${FOO_CREDS.secretAccessKey} +aws_session_token = ${FOO_CREDS.sessionToken}`.trim(); + + const DEFAULT_PATH = join(homedir(), '.aws', 'credentials'); + + it('should read credentials from ~/.aws/credentials', async () => { + __addMatcher(DEFAULT_PATH, SIMPLE_CREDS_FILE); + + expect(await fromIni()()).toEqual(DEFAULT_CREDS); + }); + + it('should read profile credentials from ~/.aws/credentials', async () => { + __addMatcher(DEFAULT_PATH, SIMPLE_CREDS_FILE); + + expect(await fromIni({profile: 'foo'})()).toEqual(FOO_CREDS); + }); + + it(`should read the profile specified in ${ENV_PROFILE}`, async () => { + __addMatcher(DEFAULT_PATH, SIMPLE_CREDS_FILE); + process.env[ENV_PROFILE] = 'foo'; + + expect(await fromIni()()).toEqual(FOO_CREDS); + }); + + it('should read from a filepath if provided', async () => { + const customPath = join(homedir(), '.aws', 'foo'); + __addMatcher(customPath, SIMPLE_CREDS_FILE); + + expect(await fromIni({filepath: customPath})()) + .toEqual(DEFAULT_CREDS); + }); + + it( + `should read from a filepath specified in ${ENV_CREDENTIALS_PATH}`, + async () => { + process.env[ENV_CREDENTIALS_PATH] = join('foo', 'bar', 'baz'); + __addMatcher( + process.env[ENV_CREDENTIALS_PATH], + SIMPLE_CREDS_FILE + ); + + expect(await fromIni()()).toEqual(DEFAULT_CREDS); + } + ); + + it( + 'should prefer a provided filepath over one specified via environment variables', + async () => { + process.env[ENV_CREDENTIALS_PATH] = join('foo', 'bar', 'baz'); + const customPath = join('fizz', 'buzz', 'pop'); + __addMatcher(customPath, ` +[default] +aws_access_key_id = ${DEFAULT_CREDS.accessKeyId} +aws_secret_access_key = ${DEFAULT_CREDS.secretAccessKey} +aws_session_token = ${DEFAULT_CREDS.sessionToken}`.trim()); + + __addMatcher(process.env[ENV_CREDENTIALS_PATH], ` +[default] +aws_access_key_id = ${FOO_CREDS.accessKeyId} +aws_secret_access_key = ${FOO_CREDS.secretAccessKey} +aws_session_token = ${FOO_CREDS.sessionToken}`.trim()); + + expect(await fromIni({filepath: customPath})()) + .toEqual(DEFAULT_CREDS); + } + ); + + it('should use $HOME when available', async () => { + process.env.HOME = `${sep}foo${sep}bar`; + __addMatcher( + `${sep}foo${sep}bar${sep}.aws${sep}credentials`, + SIMPLE_CREDS_FILE + ); + + expect(await fromIni()()).toEqual(DEFAULT_CREDS); + }); + + it('should use $USERPROFILE when available', async () => { + process.env.USERPROFILE = 'C:\\Users\\user'; + __addMatcher( + `C:\\Users\\user${sep}.aws${sep}credentials`, + SIMPLE_CREDS_FILE + ); + + expect(await fromIni()()).toEqual(DEFAULT_CREDS); + }); + + it('should use $HOMEPATH/$HOMEDRIVE when available', async () => { + process.env.HOMEDRIVE = 'D:\\'; + process.env.HOMEPATH = 'Users\\user'; + __addMatcher( + `D:\\Users\\user${sep}.aws${sep}credentials`, + SIMPLE_CREDS_FILE + ); + + expect(await fromIni()()).toEqual(DEFAULT_CREDS); + }); + + it('should prefer $HOME to $USERPROFILE', async () => { + process.env.HOME = `${sep}foo${sep}bar`; + process.env.USERPROFILE = 'C:\\Users\\user'; + + __addMatcher(`${sep}foo${sep}bar${sep}.aws${sep}credentials`, ` +[default] +aws_access_key_id = ${DEFAULT_CREDS.accessKeyId} +aws_secret_access_key = ${DEFAULT_CREDS.secretAccessKey} +aws_session_token = ${DEFAULT_CREDS.sessionToken}`.trim() + ); + + __addMatcher(`C:\\Users\\user${sep}.aws${sep}credentials`, ` +[default] +aws_access_key_id = ${FOO_CREDS.accessKeyId} +aws_secret_access_key = ${FOO_CREDS.secretAccessKey} +aws_session_token = ${FOO_CREDS.sessionToken}`.trim() + ); + + expect(await fromIni()()).toEqual(DEFAULT_CREDS); + }); + + it('should prefer $USERPROFILE to $HOMEDRIVE+$HOMEPATH', async () => { + process.env.USERPROFILE = 'C:\\Users\\user'; + process.env.HOMEDRIVE = 'D:\\'; + process.env.HOMEPATH = 'Users\\user2'; + + __addMatcher(`C:\\Users\\user${sep}.aws${sep}credentials`, ` +[default] +aws_access_key_id = ${DEFAULT_CREDS.accessKeyId} +aws_secret_access_key = ${DEFAULT_CREDS.secretAccessKey} +aws_session_token = ${DEFAULT_CREDS.sessionToken}`.trim() + ); + + __addMatcher(`D:\\Users\\user2${sep}.aws${sep}credentials`, ` +[default] +aws_access_key_id = ${FOO_CREDS.accessKeyId} +aws_secret_access_key = ${FOO_CREDS.secretAccessKey} +aws_session_token = ${FOO_CREDS.sessionToken}`.trim() + ); + + expect(await fromIni()()).toEqual(DEFAULT_CREDS); + }); + + it('should prefer $HOME to $HOMEDRIVE+$HOMEPATH', async () => { + process.env.HOME = `${sep}foo${sep}bar`; + process.env.HOMEDRIVE = 'D:\\'; + process.env.HOMEPATH = 'Users\\user2'; + + __addMatcher(`${sep}foo${sep}bar${sep}.aws${sep}credentials`, ` +[default] +aws_access_key_id = ${DEFAULT_CREDS.accessKeyId} +aws_secret_access_key = ${DEFAULT_CREDS.secretAccessKey} +aws_session_token = ${DEFAULT_CREDS.sessionToken}`.trim() + ); + + __addMatcher(`D:\\Users\\user2${sep}.aws${sep}credentials`, ` +[default] +aws_access_key_id = ${FOO_CREDS.accessKeyId} +aws_secret_access_key = ${FOO_CREDS.secretAccessKey} +aws_session_token = ${FOO_CREDS.sessionToken}`.trim() + ); + + expect(await fromIni()()).toEqual(DEFAULT_CREDS); + }); + }); + + describe('shared config file', () => { + const SIMPLE_CONFIG_FILE = ` +[default] +aws_access_key_id = ${DEFAULT_CREDS.accessKeyId} +aws_secret_access_key = ${DEFAULT_CREDS.secretAccessKey} +aws_session_token = ${DEFAULT_CREDS.sessionToken} + +[profile foo] +aws_access_key_id = ${FOO_CREDS.accessKeyId} +aws_secret_access_key = ${FOO_CREDS.secretAccessKey} +aws_session_token = ${FOO_CREDS.sessionToken}`.trim(); + + const DEFAULT_PATH = join(homedir(), '.aws', 'config'); + + it('should read credentials from ~/.aws/config', async () => { + __addMatcher(DEFAULT_PATH, SIMPLE_CONFIG_FILE); + + expect(await fromIni()()).toEqual(DEFAULT_CREDS); + }); + + it('should read profile credentials from ~/.aws/config', async () => { + __addMatcher(DEFAULT_PATH, SIMPLE_CONFIG_FILE); + + expect(await fromIni({profile: 'foo'})()).toEqual(FOO_CREDS); + }); + + it(`should read the profile specified in ${ENV_PROFILE}`, async () => { + __addMatcher(DEFAULT_PATH, SIMPLE_CONFIG_FILE); + process.env[ENV_PROFILE] = 'foo'; + + expect(await fromIni()()).toEqual(FOO_CREDS); + }); + + it('should read from a filepath if provided', async () => { + const customPath = join(homedir(), '.aws', 'foo'); + __addMatcher(customPath, SIMPLE_CONFIG_FILE); + + expect(await fromIni({configFilepath: customPath})()) + .toEqual(DEFAULT_CREDS); + }); + + it( + `should read from a filepath specified in ${ENV_CREDENTIALS_PATH}`, + async () => { + process.env[ENV_CONFIG_PATH] = join('foo', 'bar', 'baz'); + __addMatcher(process.env[ENV_CONFIG_PATH], SIMPLE_CONFIG_FILE); + + expect(await fromIni()()).toEqual(DEFAULT_CREDS); + } + ); + + it( + 'should prefer a provided filepath over one specified via environment variables', + async () => { + process.env[ENV_CONFIG_PATH] = join('foo', 'bar', 'baz'); + const customPath = join('fizz', 'buzz', 'pop'); + __addMatcher(customPath, ` +[default] +aws_access_key_id = ${DEFAULT_CREDS.accessKeyId} +aws_secret_access_key = ${DEFAULT_CREDS.secretAccessKey} +aws_session_token = ${DEFAULT_CREDS.sessionToken}`.trim()); + + __addMatcher(process.env[ENV_CONFIG_PATH], ` +[default] +aws_access_key_id = ${FOO_CREDS.accessKeyId} +aws_secret_access_key = ${FOO_CREDS.secretAccessKey} +aws_session_token = ${FOO_CREDS.sessionToken}`.trim()); + + expect(await fromIni({configFilepath: customPath})()) + .toEqual(DEFAULT_CREDS); + } + ); + + it('should use $HOME when available', async () => { + process.env.HOME = `${sep}foo${sep}bar`; + __addMatcher( + `${sep}foo${sep}bar${sep}.aws${sep}config`, + SIMPLE_CONFIG_FILE + ); + + expect(await fromIni()()).toEqual(DEFAULT_CREDS); + }); + + it('should use $USERPROFILE when available', async () => { + process.env.USERPROFILE = 'C:\\Users\\user'; + __addMatcher( + `C:\\Users\\user${sep}.aws${sep}config`, + SIMPLE_CONFIG_FILE + ); + + expect(await fromIni()()).toEqual(DEFAULT_CREDS); + }); + + it('should use $HOMEPATH/$HOMEDRIVE when available', async () => { + process.env.HOMEDRIVE = 'D:\\'; + process.env.HOMEPATH = 'Users\\user'; + __addMatcher( + `D:\\Users\\user${sep}.aws${sep}config`, + SIMPLE_CONFIG_FILE + ); + + expect(await fromIni()()).toEqual(DEFAULT_CREDS); + }); + + it('should prefer $HOME to $USERPROFILE', async () => { + process.env.HOME = `${sep}foo${sep}bar`; + process.env.USERPROFILE = 'C:\\Users\\user'; + + __addMatcher(`${sep}foo${sep}bar${sep}.aws${sep}config`, ` +[default] +aws_access_key_id = ${DEFAULT_CREDS.accessKeyId} +aws_secret_access_key = ${DEFAULT_CREDS.secretAccessKey} +aws_session_token = ${DEFAULT_CREDS.sessionToken}`.trim() + ); + + __addMatcher(`C:\\Users\\user${sep}.aws${sep}config`, ` +[default] +aws_access_key_id = ${FOO_CREDS.accessKeyId} +aws_secret_access_key = ${FOO_CREDS.secretAccessKey} +aws_session_token = ${FOO_CREDS.sessionToken}`.trim() + ); + + expect(await fromIni()()).toEqual(DEFAULT_CREDS); + }); + + it('should prefer $USERPROFILE to $HOMEDRIVE+$HOMEPATH', async () => { + process.env.USERPROFILE = 'C:\\Users\\user'; + process.env.HOMEDRIVE = 'D:\\'; + process.env.HOMEPATH = 'Users\\user2'; + + __addMatcher(`C:\\Users\\user${sep}.aws${sep}config`, ` +[default] +aws_access_key_id = ${DEFAULT_CREDS.accessKeyId} +aws_secret_access_key = ${DEFAULT_CREDS.secretAccessKey} +aws_session_token = ${DEFAULT_CREDS.sessionToken}`.trim() + ); + + __addMatcher(`D:\\Users\\user2${sep}.aws${sep}config`, ` +[default] +aws_access_key_id = ${FOO_CREDS.accessKeyId} +aws_secret_access_key = ${FOO_CREDS.secretAccessKey} +aws_session_token = ${FOO_CREDS.sessionToken}`.trim() + ); + + expect(await fromIni()()).toEqual(DEFAULT_CREDS); + }); + + it('should prefer $HOME to $HOMEDRIVE+$HOMEPATH', async () => { + process.env.HOME = `${sep}foo${sep}bar`; + process.env.HOMEDRIVE = 'D:\\'; + process.env.HOMEPATH = 'Users\\user2'; + + __addMatcher(`${sep}foo${sep}bar${sep}.aws${sep}config`, ` +[default] +aws_access_key_id = ${DEFAULT_CREDS.accessKeyId} +aws_secret_access_key = ${DEFAULT_CREDS.secretAccessKey} +aws_session_token = ${DEFAULT_CREDS.sessionToken}`.trim() + ); + + __addMatcher(`D:\\Users\\user2${sep}.aws${sep}config`, ` +[default] +aws_access_key_id = ${FOO_CREDS.accessKeyId} +aws_secret_access_key = ${FOO_CREDS.secretAccessKey} +aws_session_token = ${FOO_CREDS.sessionToken}`.trim() + ); + + expect(await fromIni()()).toEqual(DEFAULT_CREDS); + }); + }); + + describe('assume role', () => { + it( + 'should invoke a role assumer callback with credentials from a source profile', + async () => { + const roleArn = 'arn:aws:iam::123456789:role/foo'; + const sessionName = 'fooSession'; + const externalId = 'externalId'; + __addMatcher(join(homedir(), '.aws', 'credentials'), ` +[default] +aws_access_key_id = ${DEFAULT_CREDS.accessKeyId} +aws_secret_access_key = ${DEFAULT_CREDS.secretAccessKey} +aws_session_token = ${DEFAULT_CREDS.sessionToken} + +[foo] +role_arn = ${roleArn} +role_session_name = ${sessionName} +external_id = ${externalId} +source_profile = default`.trim() + ); + + const provider = fromIni({ + profile: 'foo', + roleAssumer( + sourceCreds: Credentials, + params: AssumeRoleParams + ): Promise { + expect(sourceCreds).toEqual(DEFAULT_CREDS); + expect(params.RoleArn).toEqual(roleArn); + expect(params.RoleSessionName).toEqual(sessionName); + expect(params.ExternalId).toEqual(externalId); + + return Promise.resolve(FOO_CREDS); + } + }); + + expect(await provider()).toEqual(FOO_CREDS); + } + ); + + it('should create a role session name if none provided', async () => { + __addMatcher(join(homedir(), '.aws', 'credentials'), ` +[default] +aws_access_key_id = ${DEFAULT_CREDS.accessKeyId} +aws_secret_access_key = ${DEFAULT_CREDS.secretAccessKey} +aws_session_token = ${DEFAULT_CREDS.sessionToken} + +[foo] +role_arn = arn:aws:iam::123456789:role/foo +source_profile = default`.trim() + ); + + const provider = fromIni({ + profile: 'foo', + roleAssumer( + sourceCreds: Credentials, + params: AssumeRoleParams + ): Promise { + expect(params.RoleSessionName).toBeDefined(); + + return Promise.resolve(FOO_CREDS); + } + }); + + expect(await provider()).toEqual(FOO_CREDS); + }); + + it( + 'should reject the promise with a terminal error if no role assumer provided', + async () => { + __addMatcher(join(homedir(), '.aws', 'credentials'), ` +[default] +aws_access_key_id = ${DEFAULT_CREDS.accessKeyId} +aws_secret_access_key = ${DEFAULT_CREDS.secretAccessKey} +aws_session_token = ${DEFAULT_CREDS.sessionToken} + +[foo] +role_arn = arn:aws:iam::123456789:role/foo +source_profile = bar`.trim() + ); + + await fromIni({profile: 'foo'})().then( + () => { throw new Error('The promise should have been rejected'); }, + err => { + expect((err as any).tryNextLink).toBeFalsy(); + } + ); + } + ); + + it( + 'should reject the promise if the source profile cannot be found', + async () => { + __addMatcher(join(homedir(), '.aws', 'credentials'), ` +[default] +aws_access_key_id = ${DEFAULT_CREDS.accessKeyId} +aws_secret_access_key = ${DEFAULT_CREDS.secretAccessKey} +aws_session_token = ${DEFAULT_CREDS.sessionToken} + +[foo] +role_arn = arn:aws:iam::123456789:role/foo +source_profile = bar`.trim() + ); + + await fromIni({profile: 'foo'})().then( + () => { throw new Error('The promise should have been rejected'); }, + () => { /* Promise rejected as expected */ } + ); + } + ); + + it( + 'should allow a profile in ~/.aws/credentials to use a source profile from ~/.aws/config', + async () => { + const roleArn = 'arn:aws:iam::123456789:role/foo'; + + __addMatcher(join(homedir(), '.aws', 'credentials'), ` +[foo] +role_arn = ${roleArn} +source_profile = bar`.trim() + ); + + __addMatcher(join(homedir(), '.aws', 'config'), ` +[profile bar] +aws_access_key_id = ${DEFAULT_CREDS.accessKeyId} +aws_secret_access_key = ${DEFAULT_CREDS.secretAccessKey} +aws_session_token = ${DEFAULT_CREDS.sessionToken}`.trim() + ); + + const provider = fromIni({ + profile: 'foo', + roleAssumer( + sourceCreds: Credentials, + params: AssumeRoleParams + ): Promise { + expect(sourceCreds).toEqual(DEFAULT_CREDS); + expect(params.RoleArn).toEqual(roleArn); + + return Promise.resolve(FOO_CREDS); + } + }); + + expect(await provider()).toEqual(FOO_CREDS); + } + ); + + it( + 'should allow a profile in ~/.aws/config to use a source profile from ~/.aws/credentials', + async () => { + const roleArn = 'arn:aws:iam::123456789:role/foo'; + + __addMatcher(join(homedir(), '.aws', 'config'), ` +[profile foo] +role_arn = ${roleArn} +source_profile = bar`.trim() + ); + + __addMatcher(join(homedir(), '.aws', 'credentials'), ` +[bar] +aws_access_key_id = ${DEFAULT_CREDS.accessKeyId} +aws_secret_access_key = ${DEFAULT_CREDS.secretAccessKey} +aws_session_token = ${DEFAULT_CREDS.sessionToken}`.trim() + ); + + const provider = fromIni({ + profile: 'foo', + roleAssumer( + sourceCreds: Credentials, + params: AssumeRoleParams + ): Promise { + expect(sourceCreds).toEqual(DEFAULT_CREDS); + expect(params.RoleArn).toEqual(roleArn); + + return Promise.resolve(FOO_CREDS); + } + }); + + expect(await provider()).toEqual(FOO_CREDS); + } + ); + + it( + 'should allow profiles to assume roles assuming roles assuming roles ad infinitum', + async () => { + const roleArnFor = (profile: string) => `arn:aws:iam::123456789:role/${profile}`; + const roleAssumer = jest.fn(); + roleAssumer.mockReturnValue(Promise.resolve(FOO_CREDS)); + + __addMatcher(join(homedir(), '.aws', 'config'), ` +[profile foo] +role_arn = ${roleArnFor('foo')} +source_profile = fizz + +[profile bar] +role_arn = ${roleArnFor('bar')} +source_profile = buzz + +[profile baz] +role_arn = ${roleArnFor('baz')} +source_profile = pop +`.trim() + ); + + __addMatcher(join(homedir(), '.aws', 'credentials'), ` +[default] +aws_access_key_id = ${DEFAULT_CREDS.accessKeyId} +aws_secret_access_key = ${DEFAULT_CREDS.secretAccessKey} +aws_session_token = ${DEFAULT_CREDS.sessionToken} + +[fizz] +role_arn = ${roleArnFor('fizz')} +source_profile = bar + +[buzz] +role_arn = ${roleArnFor('buzz')} +source_profile = baz + +[pop] +role_arn = ${roleArnFor('pop')} +source_profile = default +`.trim() + ); + + expect(await fromIni({roleAssumer, profile: 'foo'})()) + .toEqual(FOO_CREDS); + + expect(roleAssumer.mock.calls.length).toEqual(6); + const expectedCalls = [ + {creds: DEFAULT_CREDS, arn: roleArnFor('pop')}, + {creds: FOO_CREDS, arn: roleArnFor('baz')}, + {creds: FOO_CREDS, arn: roleArnFor('buzz')}, + {creds: FOO_CREDS, arn: roleArnFor('bar')}, + {creds: FOO_CREDS, arn: roleArnFor('fizz')}, + {creds: FOO_CREDS, arn: roleArnFor('foo')}, + ]; + + for (let {creds, arn} of expectedCalls) { + const call = roleAssumer.mock.calls.shift(); + expect(call[0]).toEqual(creds); + expect(call[1].RoleArn).toEqual(arn); + } + } + ); + + it( + 'should support assuming a role with multi-factor authentication', + async () => { + const roleArn = 'arn:aws:iam::123456789:role/foo'; + const mfaSerial = 'mfaSerial'; + const mfaCode = Date.now().toString(10); + __addMatcher(join(homedir(), '.aws', 'credentials'), ` +[default] +aws_access_key_id = ${DEFAULT_CREDS.accessKeyId} +aws_secret_access_key = ${DEFAULT_CREDS.secretAccessKey} +aws_session_token = ${DEFAULT_CREDS.sessionToken} + +[foo] +role_arn = ${roleArn} +mfa_serial = ${mfaSerial} +source_profile = default`.trim() + ); + + const provider = fromIni({ + mfaCodeProvider() { + return Promise.resolve(mfaCode); + }, + profile: 'foo', + roleAssumer( + sourceCreds: Credentials, + params: AssumeRoleParams + ): Promise { + expect(sourceCreds).toEqual(DEFAULT_CREDS); + expect(params.RoleArn).toEqual(roleArn); + expect(params.SerialNumber).toEqual(mfaSerial); + expect(params.TokenCode).toEqual(mfaCode); + + return Promise.resolve(FOO_CREDS); + } + }); + + expect(await provider()).toEqual(FOO_CREDS); + } + ); + + it( + 'should reject the promise with a terminal error if a MFA serial is present but no mfaCodeProvider was provided', + async () => { + const roleArn = 'arn:aws:iam::123456789:role/foo'; + const mfaSerial = 'mfaSerial'; + __addMatcher(join(homedir(), '.aws', 'credentials'), ` +[default] +aws_access_key_id = ${DEFAULT_CREDS.accessKeyId} +aws_secret_access_key = ${DEFAULT_CREDS.secretAccessKey} +aws_session_token = ${DEFAULT_CREDS.sessionToken} + +[foo] +role_arn = ${roleArn} +mfa_serial = ${mfaSerial} +source_profile = default`.trim() + ); + + const provider = fromIni({ + profile: 'foo', + roleAssumer: () => Promise.resolve(FOO_CREDS), + }); + + await provider().then( + () => { throw new Error('The promise should have been rejected'); }, + err => { + expect((err as any).tryNextLink).toBeFalsy(); + } + ); + } + ); + }); + + it( + 'should prefer credentials in ~/.aws/credentials to those in ~/.aws/config', + async () => { + __addMatcher(join(homedir(), '.aws', 'credentials'), ` +[default] +aws_access_key_id = ${DEFAULT_CREDS.accessKeyId} +aws_secret_access_key = ${DEFAULT_CREDS.secretAccessKey} +aws_session_token = ${DEFAULT_CREDS.sessionToken}`.trim()); + + __addMatcher(join(homedir(), '.aws', 'config'), ` +[default] +aws_access_key_id = ${FOO_CREDS.accessKeyId} +aws_secret_access_key = ${FOO_CREDS.secretAccessKey} +aws_session_token = ${FOO_CREDS.sessionToken}`.trim()); + + expect(await fromIni()()).toEqual(DEFAULT_CREDS); + } + ); + + it('should reject credentials with no access key', async () => { + __addMatcher(join(homedir(), '.aws', 'credentials'), ` +[default] +aws_secret_access_key = ${DEFAULT_CREDS.secretAccessKey} + `.trim()); + + await fromIni()().then( + () => { throw new Error('The promise should have been rejected'); }, + () => { /* Promise rejected as expected */ } + ); + }); + + it('should reject credentials with no secret key', async () => { + __addMatcher(join(homedir(), '.aws', 'credentials'), ` +[default] +aws_access_key_id = ${DEFAULT_CREDS.accessKeyId} + `.trim()); + + await fromIni()().then( + () => { throw new Error('The promise should have been rejected'); }, + () => { /* Promise rejected as expected */ } + ); + }); + + it('should not merge profile values together', async () => { + __addMatcher(join(homedir(), '.aws', 'credentials'), ` +[default] +aws_access_key_id = ${DEFAULT_CREDS.accessKeyId} + `.trim()); + + __addMatcher(join(homedir(), '.aws', 'config'), ` +[default] +aws_secret_access_key = ${FOO_CREDS.secretAccessKey} + `.trim()); + + await fromIni()().then( + () => { throw new Error('The promise should have been rejected'); }, + () => { /* Promise rejected as expected */ } + ); + }); +}); diff --git a/packages/credential-provider-ini/index.ts b/packages/credential-provider-ini/index.ts new file mode 100755 index 000000000000..ab5b96e04067 --- /dev/null +++ b/packages/credential-provider-ini/index.ts @@ -0,0 +1,267 @@ +import {CredentialProvider, Credentials} from '@aws/types'; +import {homedir} from 'os'; +import {join, sep} from 'path'; +import {readFile} from 'fs'; +import {CredentialError} from '@aws/credential-provider-base'; + +const DEFAULT_PROFILE = 'default'; +export const ENV_PROFILE = 'AWS_PROFILE'; +export const ENV_CREDENTIALS_PATH = 'AWS_SHARED_CREDENTIALS_FILE'; +export const ENV_CONFIG_PATH = 'AWS_CONFIG_FILE'; + +/** + * @see http://docs.aws.amazon.com/AWSJavaScriptSDK/latest/AWS/STS.html#assumeRole-property + * TODO update the above to link to V3 docs + */ +export interface AssumeRoleParams { + /** + * @copyDoc + */ + RoleArn: string; + + /** + * A name for the assumed role session. + */ + RoleSessionName: string; + + /** + * A unique identifier that is used by third parties when assuming roles in + * their customers' accounts. + */ + ExternalId?: string; + + /** + * The identification number of the MFA device that is associated with the + * user who is making the `AssumeRole` call. + */ + SerialNumber?: string; + + /** + * The value provided by the MFA device. + */ + TokenCode?: string; +} + +export interface FromIniInit { + /** + * The configuration profile to use. + */ + profile?: string; + + /** + * The path at which to locate the ini credentials file. Defaults to the + * value of the `AWS_SHARED_CREDENTIALS_FILE` environment variable (if + * defined) or `~/.aws/credentials` otherwise. + */ + filepath?: string; + + /** + * The path at which to locate the ini config file. Defaults to the value of + * the `AWS_CONFIG_FILE` environment variable (if defined) or + * `~/.aws/config` otherwise. + */ + configFilepath?: string; + + /** + * A function that returna a promise fulfilled with an MFA token code for + * the provided MFA Serial code. If a profile requires an MFA code and + * `mfaCodeProvider` is not a valid function, the credential provider + * promise will be rejected. + * + * @param mfaSerial The serial code of the MFA device specified. + */ + mfaCodeProvider?: (mfaSerial: string) => Promise; + + /** + * A function that assumes a role and returns a promise fulfilled with + * credentials for the assumed role. + * + * @param sourceCreds The credentials with which to assume a role. + * @param params + */ + roleAssumer?: ( + sourceCreds: Credentials, + params: AssumeRoleParams + ) => Promise; +} + +interface Profile { + [key: string]: string; +} + +interface ParsedIniData { + [key: string]: Profile; +} + +interface StaticCredsProfile { + aws_access_key_id: string; + aws_secret_access_key: string; + aws_session_token?: string; +} + +function isStaticCredsProfile(arg: any): arg is StaticCredsProfile { + return Boolean(arg) && typeof arg === 'object' + && typeof arg.aws_access_key_id === 'string' + && typeof arg.aws_secret_access_key === 'string' + && ['undefined', 'string'].indexOf(typeof arg.aws_session_token) > -1; +} + +interface AssumeRoleProfile { + role_arn: string; + source_profile: string; + role_session_name?: string; + external_id?: string; + mfa_serial?: string; +} + +function isAssumeRoleProfile(arg: any): arg is AssumeRoleProfile { + return Boolean(arg) && typeof arg === 'object' + && typeof arg.role_arn === 'string' + && typeof arg.source_profile === 'string' + && ['undefined', 'string'].indexOf(typeof arg.role_session_name) > -1 + && ['undefined', 'string'].indexOf(typeof arg.external_id) > -1 + && ['undefined', 'string'].indexOf(typeof arg.mfa_serial) > -1; +} + +/** + * Creates a credential provider that will read from ini files and supports + * role assumption and multi-factor authentication. + */ +export function fromIni(init: FromIniInit = {}): CredentialProvider { + return () => parseKnownFiles(init).then(profiles => { + const { + profile = process.env[ENV_PROFILE] || DEFAULT_PROFILE, + } = init; + + return resolveProfileData(profile, profiles, init); + }); +} + +async function resolveProfileData( + profile: string, + profiles: ParsedIniData, + options: FromIniInit +): Promise { + const data = profiles[profile]; + if (isStaticCredsProfile(data)) { + return Promise.resolve({ + accessKeyId: data.aws_access_key_id, + secretAccessKey: data.aws_secret_access_key, + sessionToken: data.aws_session_token, + }); + } else if (isAssumeRoleProfile(data)) { + if (!options.roleAssumer) { + throw new CredentialError( + `Profile ${profile} requires a role to be assumed, but no` + + ` role assumption callback was provided.`, + false + ); + } + + const { + external_id: ExternalId, + mfa_serial, + role_arn: RoleArn, + role_session_name: RoleSessionName = 'aws-sdk-js-' + Date.now(), + source_profile, + } = data; + + const sourceCreds = fromIni({ + ...options, + profile: source_profile, + })(); + const params: AssumeRoleParams = {RoleArn, RoleSessionName, ExternalId}; + if (mfa_serial) { + if (!options.mfaCodeProvider) { + throw new CredentialError( + `Profile ${profile} requires multi-factor authentication,` + + ` but no MFA code callback was provided.`, + false + ); + } + params.SerialNumber = mfa_serial; + params.TokenCode = await options.mfaCodeProvider(mfa_serial); + } + + return options.roleAssumer(await sourceCreds, params); + } + + throw new CredentialError( + `Profile ${profile} could not be found or parsed in shared` + + ` credentials file.`, + profile === DEFAULT_PROFILE + ); +} + +function parseIni(iniData: string): ParsedIniData { + const map: ParsedIniData = {}; + let currentSection: string|undefined; + for (let line of iniData.split(/\r?\n/)) { + line = line.split(/(^|\s)[;#]/)[0]; // remove comments + const section = line.match(/^\s*\[([^\[\]]+)]\s*$/); + if (section) { + currentSection = section[1]; + } else if (currentSection) { + const item = line.match(/^\s*(.+?)\s*=\s*(.+?)\s*$/); + if (item) { + map[currentSection] = map[currentSection] || {}; + map[currentSection][item[1]] = item[2]; + } + } + } + + return map; +} + +function parseKnownFiles(init: FromIniInit): Promise { + const { + filepath = process.env[ENV_CREDENTIALS_PATH] + || join(getHomeDir(), '.aws', 'credentials'), + configFilepath = process.env[ENV_CONFIG_PATH] + || join(getHomeDir(), '.aws', 'config'), + } = init; + return Promise.all([ + slurpFile(configFilepath).then(parseIni).catch(() => { return {}; }), + slurpFile(filepath).then(parseIni).catch(() => { return {}; }), + ]).then((parsedFiles: Array) => { + const [config = {}, credentials = {}] = parsedFiles; + const profiles: ParsedIniData = {}; + + for (let profile of Object.keys(config)) { + profiles[profile.replace(/^profile\s/, '')] = config[profile]; + } + + for (let profile of Object.keys(credentials)) { + profiles[profile] = credentials[profile]; + } + + return profiles; + }); +} + +function slurpFile(path: string): Promise { + return new Promise((resolve, reject) => { + readFile(path, 'utf8', (err, data) => { + if (err) { + reject(err); + } else { + resolve(data); + } + }); + }); +} + +function getHomeDir(): string { + const { + HOME, + USERPROFILE, + HOMEPATH, + HOMEDRIVE = `C:${sep}`, + } = process.env; + + if (HOME) return HOME; + if (USERPROFILE) return USERPROFILE; + if (HOMEPATH) return `${HOMEDRIVE}${HOMEPATH}`; + + return homedir(); +} diff --git a/packages/credential-provider-ini/package.json b/packages/credential-provider-ini/package.json new file mode 100644 index 000000000000..a086b5004d7f --- /dev/null +++ b/packages/credential-provider-ini/package.json @@ -0,0 +1,28 @@ +{ + "name": "@aws/credential-provider-ini", + "version": "0.0.1", + "private": true, + "description": "AWS credential provider that sources credentials from ~/.aws/credentials and ~/.aws/config", + "main": "index.js", + "scripts": { + "prepublishOnly": "tsc", + "pretest": "tsc", + "test": "jest" + }, + "keywords": [ + "aws", + "credentials" + ], + "author": "aws-javascript-sdk-team@amazon.com", + "license": "UNLICENSED", + "dependencies": { + "@aws/credential-provider-base": "^0.0.1", + "@aws/types": "^0.0.1" + }, + "devDependencies": { + "@types/jest": "^19.2.2", + "@types/node": "^7.0.12", + "jest": "^19.0.2", + "typescript": "^2.3" + } +} diff --git a/packages/credential-provider-ini/tsconfig.json b/packages/credential-provider-ini/tsconfig.json new file mode 100755 index 000000000000..c5eb3a060fe9 --- /dev/null +++ b/packages/credential-provider-ini/tsconfig.json @@ -0,0 +1,9 @@ +{ + "compilerOptions": { + "module": "commonjs", + "target": "es6", + "declaration": true, + "strict": true, + "sourceMap": true + } +} From d6c545c1d39cae71cb9b5514e967658486a52187 Mon Sep 17 00:00:00 2001 From: Jonathan Eskew Date: Wed, 21 Jun 2017 12:08:54 -0700 Subject: [PATCH 2/3] Drop the compile target to es5 --- packages/credential-provider-ini/__mocks__/fs.ts | 12 ++++++------ packages/credential-provider-ini/__tests__/index.ts | 8 ++++---- packages/credential-provider-ini/index.ts | 12 ++++++------ packages/credential-provider-ini/package.json | 7 ++++--- packages/credential-provider-ini/tsconfig.json | 9 +++++++-- 5 files changed, 27 insertions(+), 21 deletions(-) diff --git a/packages/credential-provider-ini/__mocks__/fs.ts b/packages/credential-provider-ini/__mocks__/fs.ts index e216fdbdb435..7b857830e2c1 100644 --- a/packages/credential-provider-ini/__mocks__/fs.ts +++ b/packages/credential-provider-ini/__mocks__/fs.ts @@ -5,14 +5,14 @@ interface FsModule { } const fs: FsModule = jest.genMockFromModule('fs'); -const matchers = new Map(); +let matchers: {[key: string]: string} = {}; function __addMatcher(toMatch: string, toReturn: string): void { - matchers.set(toMatch, toReturn); + matchers[toMatch] = toReturn; } function __clearMatchers(): void { - matchers.clear(); + matchers = {}; } function readFile( @@ -20,9 +20,9 @@ function readFile( encoding: string, callback: (err: Error|null, data?: string) => void ): void { - for (let [matcher, data] of matchers.entries()) { - if (matcher === path) { - callback(null, data); + for (let key of Object.keys(matchers)) { + if (key === path) { + callback(null, matchers[key]); return; } } diff --git a/packages/credential-provider-ini/__tests__/index.ts b/packages/credential-provider-ini/__tests__/index.ts index f57f287c67d9..f186d2f1d109 100644 --- a/packages/credential-provider-ini/__tests__/index.ts +++ b/packages/credential-provider-ini/__tests__/index.ts @@ -35,10 +35,10 @@ const envAtLoadTime: {[key: string]: string} = [ 'USERPROFILE', 'HOMEPATH', 'HOMEDRIVE', -].reduce((envState, varName) => Object.assign( - envState, - {[varName]: process.env[varName]} -), {}); +].reduce((envState: {[key: string]: string}, varName: string) => { + envState[varName] = process.env[varName]; + return envState; +}, {}); beforeEach(() => { __clearMatchers(); diff --git a/packages/credential-provider-ini/index.ts b/packages/credential-provider-ini/index.ts index ab5b96e04067..d43e351c0ea5 100755 --- a/packages/credential-provider-ini/index.ts +++ b/packages/credential-provider-ini/index.ts @@ -138,11 +138,11 @@ export function fromIni(init: FromIniInit = {}): CredentialProvider { } async function resolveProfileData( - profile: string, + profileName: string, profiles: ParsedIniData, options: FromIniInit ): Promise { - const data = profiles[profile]; + const data = profiles[profileName]; if (isStaticCredsProfile(data)) { return Promise.resolve({ accessKeyId: data.aws_access_key_id, @@ -152,7 +152,7 @@ async function resolveProfileData( } else if (isAssumeRoleProfile(data)) { if (!options.roleAssumer) { throw new CredentialError( - `Profile ${profile} requires a role to be assumed, but no` + + `Profile ${profileName} requires a role to be assumed, but no` + ` role assumption callback was provided.`, false ); @@ -174,7 +174,7 @@ async function resolveProfileData( if (mfa_serial) { if (!options.mfaCodeProvider) { throw new CredentialError( - `Profile ${profile} requires multi-factor authentication,` + + `Profile ${profileName} requires multi-factor authentication,` + ` but no MFA code callback was provided.`, false ); @@ -187,9 +187,9 @@ async function resolveProfileData( } throw new CredentialError( - `Profile ${profile} could not be found or parsed in shared` + + `Profile ${profileName} could not be found or parsed in shared` + ` credentials file.`, - profile === DEFAULT_PROFILE + profileName === DEFAULT_PROFILE ); } diff --git a/packages/credential-provider-ini/package.json b/packages/credential-provider-ini/package.json index a086b5004d7f..45b4a76b93cb 100644 --- a/packages/credential-provider-ini/package.json +++ b/packages/credential-provider-ini/package.json @@ -17,12 +17,13 @@ "license": "UNLICENSED", "dependencies": { "@aws/credential-provider-base": "^0.0.1", - "@aws/types": "^0.0.1" + "@aws/types": "^0.0.1", + "tslib": "^1.7.1" }, "devDependencies": { - "@types/jest": "^19.2.2", + "@types/jest": "^20.0.1", "@types/node": "^7.0.12", - "jest": "^19.0.2", + "jest": "^20.0.4", "typescript": "^2.3" } } diff --git a/packages/credential-provider-ini/tsconfig.json b/packages/credential-provider-ini/tsconfig.json index c5eb3a060fe9..19f1c107f380 100755 --- a/packages/credential-provider-ini/tsconfig.json +++ b/packages/credential-provider-ini/tsconfig.json @@ -1,9 +1,14 @@ { "compilerOptions": { "module": "commonjs", - "target": "es6", + "target": "es5", "declaration": true, "strict": true, - "sourceMap": true + "sourceMap": true, + "importHelpers": true, + "lib": [ + "es5", + "es2015.promise" + ] } } From 701aacbd31b028be797e402e2f240a238ba648ee Mon Sep 17 00:00:00 2001 From: Jonathan Eskew Date: Wed, 21 Jun 2017 18:52:52 -0700 Subject: [PATCH 3/3] Add role assumption cycle detection --- .../__tests__/index.ts | 154 +++++++++++++----- packages/credential-provider-ini/index.ts | 72 ++++---- 2 files changed, 154 insertions(+), 72 deletions(-) diff --git a/packages/credential-provider-ini/__tests__/index.ts b/packages/credential-provider-ini/__tests__/index.ts index f186d2f1d109..f3a8efed48f6 100644 --- a/packages/credential-provider-ini/__tests__/index.ts +++ b/packages/credential-provider-ini/__tests__/index.ts @@ -27,6 +27,12 @@ const FOO_CREDS = { sessionToken: 'baz', }; +const FIZZ_CREDS = { + accessKeyId: 'fizz', + secretAccessKey: 'buzz', + sessionToken: 'pop', +}; + const envAtLoadTime: {[key: string]: string} = [ ENV_CONFIG_PATH, ENV_CREDENTIALS_PATH, @@ -55,13 +61,11 @@ afterAll(() => { }); describe('fromIni', () => { - it('should flag a lack of credentials as a non-terminal error', async () => { - await fromIni()().then( - () => { throw new Error('The promise should have been rejected.'); }, - err => { - expect((err as CredentialError).tryNextLink).toBe(true); - } - ); + it('should flag a lack of credentials as a non-terminal error', () => { + return expect(fromIni()()).rejects.toMatchObject({ + message: 'Profile default could not be found or parsed in shared credentials file.', + tryNextLink: true, + }); }); describe('shared credentials file', () => { @@ -475,7 +479,7 @@ source_profile = default`.trim() it( 'should reject the promise with a terminal error if no role assumer provided', - async () => { + () => { __addMatcher(join(homedir(), '.aws', 'credentials'), ` [default] aws_access_key_id = ${DEFAULT_CREDS.accessKeyId} @@ -487,18 +491,16 @@ role_arn = arn:aws:iam::123456789:role/foo source_profile = bar`.trim() ); - await fromIni({profile: 'foo'})().then( - () => { throw new Error('The promise should have been rejected'); }, - err => { - expect((err as any).tryNextLink).toBeFalsy(); - } - ); + return expect(fromIni({profile: 'foo'})()).rejects.toMatchObject({ + message: 'Profile foo requires a role to be assumed, but no role assumption callback was provided.', + tryNextLink: false, + }); } ); it( 'should reject the promise if the source profile cannot be found', - async () => { + () => { __addMatcher(join(homedir(), '.aws', 'credentials'), ` [default] aws_access_key_id = ${DEFAULT_CREDS.accessKeyId} @@ -510,10 +512,15 @@ role_arn = arn:aws:iam::123456789:role/foo source_profile = bar`.trim() ); - await fromIni({profile: 'foo'})().then( - () => { throw new Error('The promise should have been rejected'); }, - () => { /* Promise rejected as expected */ } - ); + const provider = fromIni({ + profile: 'foo', + roleAssumer: jest.fn() + }); + + return expect(provider()).rejects.toMatchObject({ + message: 'Profile bar could not be found or parsed in shared credentials file.', + tryNextLink: false, + }); } ); @@ -692,7 +699,7 @@ source_profile = default`.trim() it( 'should reject the promise with a terminal error if a MFA serial is present but no mfaCodeProvider was provided', - async () => { + () => { const roleArn = 'arn:aws:iam::123456789:role/foo'; const mfaSerial = 'mfaSerial'; __addMatcher(join(homedir(), '.aws', 'credentials'), ` @@ -712,12 +719,10 @@ source_profile = default`.trim() roleAssumer: () => Promise.resolve(FOO_CREDS), }); - await provider().then( - () => { throw new Error('The promise should have been rejected'); }, - err => { - expect((err as any).tryNextLink).toBeFalsy(); - } - ); + return expect(provider()).rejects.toMatchObject({ + message: 'Profile foo requires multi-factor authentication, but no MFA code callback was provided.', + tryNextLink: false, + }); } ); }); @@ -741,31 +746,31 @@ aws_session_token = ${FOO_CREDS.sessionToken}`.trim()); } ); - it('should reject credentials with no access key', async () => { + it('should reject credentials with no access key', () => { __addMatcher(join(homedir(), '.aws', 'credentials'), ` [default] aws_secret_access_key = ${DEFAULT_CREDS.secretAccessKey} `.trim()); - await fromIni()().then( - () => { throw new Error('The promise should have been rejected'); }, - () => { /* Promise rejected as expected */ } - ); + return expect(fromIni()()).rejects.toMatchObject({ + message: 'Profile default could not be found or parsed in shared credentials file.', + tryNextLink: true, + }); }); - it('should reject credentials with no secret key', async () => { + it('should reject credentials with no secret key', () => { __addMatcher(join(homedir(), '.aws', 'credentials'), ` [default] aws_access_key_id = ${DEFAULT_CREDS.accessKeyId} `.trim()); - await fromIni()().then( - () => { throw new Error('The promise should have been rejected'); }, - () => { /* Promise rejected as expected */ } - ); + return expect(fromIni()()).rejects.toMatchObject({ + message: 'Profile default could not be found or parsed in shared credentials file.', + tryNextLink: true, + }); }); - it('should not merge profile values together', async () => { + it('should not merge profile values together', () => { __addMatcher(join(homedir(), '.aws', 'credentials'), ` [default] aws_access_key_id = ${DEFAULT_CREDS.accessKeyId} @@ -776,9 +781,76 @@ aws_access_key_id = ${DEFAULT_CREDS.accessKeyId} aws_secret_access_key = ${FOO_CREDS.secretAccessKey} `.trim()); - await fromIni()().then( - () => { throw new Error('The promise should have been rejected'); }, - () => { /* Promise rejected as expected */ } - ); + return expect(fromIni()()).rejects.toMatchObject({ + message: 'Profile default could not be found or parsed in shared credentials file.', + tryNextLink: true, + }); }); + + it( + 'should treat a profile with static credentials and role assumption keys as an assume role profile', + () => { + __addMatcher(join(homedir(), '.aws', 'credentials'), ` +[default] +aws_secret_access_key = ${DEFAULT_CREDS.secretAccessKey} +aws_secret_access_key = ${DEFAULT_CREDS.secretAccessKey} +role_arn = foo +source_profile = foo + +[foo] +aws_access_key_id = ${FOO_CREDS.accessKeyId} +aws_secret_access_key = ${FOO_CREDS.secretAccessKey} +aws_session_token = ${FOO_CREDS.sessionToken} + `.trim()); + + const provider = fromIni({ + roleAssumer( + sourceCreds: Credentials, + params: AssumeRoleParams + ): Promise { + expect(sourceCreds).toEqual(FOO_CREDS); + expect(params.RoleArn).toEqual('foo'); + + return Promise.resolve(FIZZ_CREDS); + } + }); + + return expect(provider()).resolves.toEqual(FIZZ_CREDS); + } + ); + + it( + 'should reject credentials when profile role assumption creates a cycle', + () => { + __addMatcher(join(homedir(), '.aws', 'credentials'), ` +[default] +role_arn = foo +source_profile = foo + +[bar] +role_arn = baz +source_profile = baz + +[fizz] +role_arn = buzz +source_profile = foo + `.trim()); + + __addMatcher(join(homedir(), '.aws', 'config'), ` +[profile foo] +role_arn = bar +source_profile = bar + +[profile baz] +role_arn = fizz +source_profile = fizz + `.trim()); + const provider = fromIni({roleAssumer: jest.fn()}); + + return expect(provider()).rejects.toMatchObject({ + message: 'Detected a cycle attempting to resolve credentials for profile default. Profiles visited: foo, bar, baz, fizz', + tryNextLink: false, + }); + } + ); }); diff --git a/packages/credential-provider-ini/index.ts b/packages/credential-provider-ini/index.ts index d43e351c0ea5..6d7558c051e8 100755 --- a/packages/credential-provider-ini/index.ts +++ b/packages/credential-provider-ini/index.ts @@ -93,10 +93,9 @@ interface ParsedIniData { [key: string]: Profile; } -interface StaticCredsProfile { +interface StaticCredsProfile extends Profile{ aws_access_key_id: string; aws_secret_access_key: string; - aws_session_token?: string; } function isStaticCredsProfile(arg: any): arg is StaticCredsProfile { @@ -106,12 +105,9 @@ function isStaticCredsProfile(arg: any): arg is StaticCredsProfile { && ['undefined', 'string'].indexOf(typeof arg.aws_session_token) > -1; } -interface AssumeRoleProfile { +interface AssumeRoleProfile extends Profile{ role_arn: string; source_profile: string; - role_session_name?: string; - external_id?: string; - mfa_serial?: string; } function isAssumeRoleProfile(arg: any): arg is AssumeRoleProfile { @@ -128,28 +124,33 @@ function isAssumeRoleProfile(arg: any): arg is AssumeRoleProfile { * role assumption and multi-factor authentication. */ export function fromIni(init: FromIniInit = {}): CredentialProvider { - return () => parseKnownFiles(init).then(profiles => { - const { - profile = process.env[ENV_PROFILE] || DEFAULT_PROFILE, - } = init; + return () => parseKnownFiles(init).then(profiles => resolveProfileData( + getMasterProfileName(init), + profiles, + init + )); +} - return resolveProfileData(profile, profiles, init); - }); +function getMasterProfileName(init: FromIniInit): string { + return init.profile || process.env[ENV_PROFILE] || DEFAULT_PROFILE; } async function resolveProfileData( profileName: string, profiles: ParsedIniData, - options: FromIniInit + options: FromIniInit, + visitedProfiles: {[profileName: string]: true} = {} ): Promise { const data = profiles[profileName]; - if (isStaticCredsProfile(data)) { - return Promise.resolve({ - accessKeyId: data.aws_access_key_id, - secretAccessKey: data.aws_secret_access_key, - sessionToken: data.aws_session_token, - }); - } else if (isAssumeRoleProfile(data)) { + if (isAssumeRoleProfile(data)) { + const { + external_id: ExternalId, + mfa_serial, + role_arn: RoleArn, + role_session_name: RoleSessionName = 'aws-sdk-js-' + Date.now(), + source_profile + } = data; + if (!options.roleAssumer) { throw new CredentialError( `Profile ${profileName} requires a role to be assumed, but no` + @@ -158,18 +159,21 @@ async function resolveProfileData( ); } - const { - external_id: ExternalId, - mfa_serial, - role_arn: RoleArn, - role_session_name: RoleSessionName = 'aws-sdk-js-' + Date.now(), - source_profile, - } = data; + if (source_profile in visitedProfiles) { + throw new CredentialError( + `Detected a cycle attempting to resolve credentials for profile` + + ` ${getMasterProfileName(options)}. Profiles visited: ` + + Object.keys(visitedProfiles).join(', '), + false + ); + } - const sourceCreds = fromIni({ - ...options, - profile: source_profile, - })(); + const sourceCreds = resolveProfileData( + source_profile, + profiles, + options, + {...visitedProfiles, [source_profile]: true} + ); const params: AssumeRoleParams = {RoleArn, RoleSessionName, ExternalId}; if (mfa_serial) { if (!options.mfaCodeProvider) { @@ -184,6 +188,12 @@ async function resolveProfileData( } return options.roleAssumer(await sourceCreds, params); + } else if (isStaticCredsProfile(data)) { + return Promise.resolve({ + accessKeyId: data.aws_access_key_id, + secretAccessKey: data.aws_secret_access_key, + sessionToken: data.aws_session_token, + }); } throw new CredentialError(