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..7b857830e2c1 --- /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'); +let matchers: {[key: string]: string} = {}; + +function __addMatcher(toMatch: string, toReturn: string): void { + matchers[toMatch] = toReturn; +} + +function __clearMatchers(): void { + matchers = {}; +} + +function readFile( + path: string, + encoding: string, + callback: (err: Error|null, data?: string) => void +): void { + for (let key of Object.keys(matchers)) { + if (key === path) { + callback(null, matchers[key]); + 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..f3a8efed48f6 --- /dev/null +++ b/packages/credential-provider-ini/__tests__/index.ts @@ -0,0 +1,856 @@ +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 FIZZ_CREDS = { + accessKeyId: 'fizz', + secretAccessKey: 'buzz', + sessionToken: 'pop', +}; + +const envAtLoadTime: {[key: string]: string} = [ + ENV_CONFIG_PATH, + ENV_CREDENTIALS_PATH, + ENV_PROFILE, + 'HOME', + 'USERPROFILE', + 'HOMEPATH', + 'HOMEDRIVE', +].reduce((envState: {[key: string]: string}, varName: string) => { + envState[varName] = process.env[varName]; + return envState; +}, {}); + +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', () => { + return expect(fromIni()()).rejects.toMatchObject({ + message: 'Profile default could not be found or parsed in shared credentials file.', + tryNextLink: 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', + () => { + __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() + ); + + 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', + () => { + __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() + ); + + 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, + }); + } + ); + + 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', + () => { + 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), + }); + + return expect(provider()).rejects.toMatchObject({ + message: 'Profile foo requires multi-factor authentication, but no MFA code callback was provided.', + tryNextLink: false, + }); + } + ); + }); + + 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', () => { + __addMatcher(join(homedir(), '.aws', 'credentials'), ` +[default] +aws_secret_access_key = ${DEFAULT_CREDS.secretAccessKey} + `.trim()); + + 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', () => { + __addMatcher(join(homedir(), '.aws', 'credentials'), ` +[default] +aws_access_key_id = ${DEFAULT_CREDS.accessKeyId} + `.trim()); + + 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', () => { + __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()); + + 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 new file mode 100755 index 000000000000..6d7558c051e8 --- /dev/null +++ b/packages/credential-provider-ini/index.ts @@ -0,0 +1,277 @@ +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 extends Profile{ + aws_access_key_id: string; + aws_secret_access_key: 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 extends Profile{ + role_arn: string; + source_profile: 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 => resolveProfileData( + getMasterProfileName(init), + 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, + visitedProfiles: {[profileName: string]: true} = {} +): Promise { + const data = profiles[profileName]; + 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` + + ` role assumption callback was provided.`, + false + ); + } + + 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 = resolveProfileData( + source_profile, + profiles, + options, + {...visitedProfiles, [source_profile]: true} + ); + const params: AssumeRoleParams = {RoleArn, RoleSessionName, ExternalId}; + if (mfa_serial) { + if (!options.mfaCodeProvider) { + throw new CredentialError( + `Profile ${profileName} 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); + } 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( + `Profile ${profileName} could not be found or parsed in shared` + + ` credentials file.`, + profileName === 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..45b4a76b93cb --- /dev/null +++ b/packages/credential-provider-ini/package.json @@ -0,0 +1,29 @@ +{ + "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", + "tslib": "^1.7.1" + }, + "devDependencies": { + "@types/jest": "^20.0.1", + "@types/node": "^7.0.12", + "jest": "^20.0.4", + "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..19f1c107f380 --- /dev/null +++ b/packages/credential-provider-ini/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "module": "commonjs", + "target": "es5", + "declaration": true, + "strict": true, + "sourceMap": true, + "importHelpers": true, + "lib": [ + "es5", + "es2015.promise" + ] + } +}