diff --git a/packages/credential-provider/.gitignore b/packages/credential-provider/.gitignore new file mode 100644 index 000000000000..b5d6f1c7b0fa --- /dev/null +++ b/packages/credential-provider/.gitignore @@ -0,0 +1,4 @@ +/node_modules/ +*.js +*.js.map +*.d.ts diff --git a/packages/credential-provider/__mocks__/fs.ts b/packages/credential-provider/__mocks__/fs.ts new file mode 100644 index 000000000000..e216fdbdb435 --- /dev/null +++ b/packages/credential-provider/__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/__mocks__/os.ts b/packages/credential-provider/__mocks__/os.ts new file mode 100644 index 000000000000..2768b30f83b4 --- /dev/null +++ b/packages/credential-provider/__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/__tests__/chain.ts b/packages/credential-provider/__tests__/chain.ts new file mode 100644 index 000000000000..d2a7c861e997 --- /dev/null +++ b/packages/credential-provider/__tests__/chain.ts @@ -0,0 +1,70 @@ +import {chain} from "../lib/chain"; +import {fromCredentials} from "../lib/fromCredentials"; +import {isCredentials} from "../lib/isCredentials"; +import {CredentialError} from "../lib/CredentialError"; + +describe('chain', () => { + it('should distill many credential providers into one', async () => { + const provider = chain( + fromCredentials({accessKeyId: 'foo', secretAccessKey: 'bar'}), + fromCredentials({accessKeyId: 'baz', secretAccessKey: 'quux'}), + ); + + expect(isCredentials(await provider())).toBe(true); + }); + + it('should return the resolved value of the first successful promise', async () => { + const creds = {accessKeyId: 'foo', secretAccessKey: 'bar'}; + const provider = chain( + () => Promise.reject(new CredentialError('Move along')), + () => Promise.reject(new CredentialError('Nothing to see here')), + fromCredentials(creds) + ); + + expect(await provider()).toEqual(creds); + }); + + it('should not invoke subsequent providers one resolves', async () => { + const creds = {accessKeyId: 'foo', secretAccessKey: 'bar'}; + const providers = [ + jest.fn(() => Promise.reject(new CredentialError('Move along'))), + jest.fn(() => Promise.resolve(creds)), + jest.fn(() => fail('This provider should not be invoked')) + ]; + + expect(await chain(...providers)()).toEqual(creds); + expect(providers[0].mock.calls.length).toBe(1); + expect(providers[1].mock.calls.length).toBe(1); + expect(providers[2].mock.calls.length).toBe(0); + }); + + it( + 'should not invoke subsequent providers one is rejected with a terminal error', + async () => { + const providers = [ + jest.fn(() => Promise.reject(new CredentialError('Move along'))), + jest.fn(() => Promise.reject( + new CredentialError('Stop here', false) + )), + jest.fn(() => fail('This provider should not be invoked')) + ]; + + await chain(...providers)().then( + () => { throw new Error('The promise should have been rejected'); }, + err => { + expect(err.message).toBe('Stop here'); + expect(providers[0].mock.calls.length).toBe(1); + expect(providers[1].mock.calls.length).toBe(1); + expect(providers[2].mock.calls.length).toBe(0); + } + ); + } + ); + + it('should reject chains with no links', async () => { + await chain()().then( + () => { throw new Error('The promise should have been rejected'); }, + () => { /* Promise rejected as expected */ } + ); + }); +}); diff --git a/packages/credential-provider/__tests__/fromContainerMetadata.ts b/packages/credential-provider/__tests__/fromContainerMetadata.ts new file mode 100644 index 000000000000..71471510af8f --- /dev/null +++ b/packages/credential-provider/__tests__/fromContainerMetadata.ts @@ -0,0 +1,218 @@ +import { + ENV_CMDS_AUTH_TOKEN, + ENV_CMDS_FULL_URI, + ENV_CMDS_RELATIVE_URI, + fromContainerMetadata +} from "../lib/fromContainerMetadata"; +import {httpGet} from "../lib/remoteProvider/httpGet"; +import { + fromImdsCredentials, + ImdsCredentials +} from "../lib/remoteProvider/ImdsCredentials"; +import MockInstance = jest.MockInstance; +import {RequestOptions} from "http"; + +interface HttpGet { + (options: RequestOptions): Promise; +} + +const mockHttpGet = >httpGet; +jest.mock('../lib/remoteProvider/httpGet', () => ({httpGet: jest.fn()})); + +const relativeUri = process.env[ENV_CMDS_RELATIVE_URI]; +const fullUri = process.env[ENV_CMDS_FULL_URI]; +const authToken = process.env[ENV_CMDS_AUTH_TOKEN]; + +beforeEach(() => { + mockHttpGet.mockReset(); + delete process.env[ENV_CMDS_RELATIVE_URI]; + delete process.env[ENV_CMDS_FULL_URI]; + delete process.env[ENV_CMDS_AUTH_TOKEN]; +}); + +afterAll(() => { + process.env[ENV_CMDS_RELATIVE_URI] = relativeUri; + process.env[ENV_CMDS_FULL_URI] = fullUri; + process.env[ENV_CMDS_AUTH_TOKEN] = authToken; +}); + +describe('fromContainerMetadata', () => { + const creds: ImdsCredentials = Object.freeze({ + AccessKeyId: 'foo', + SecretAccessKey: 'bar', + Token: 'baz', + Expiration: new Date().toISOString(), + }); + + it( + 'should reject the promise with a terminal error if the container credentials environment variable is not set', + async () => { + await fromContainerMetadata()().then( + () => { throw new Error('The promise should have been rejected'); }, + err => { + expect((err as any).tryNextLink).toBeFalsy(); + } + ); + } + ); + + it( + `should inject an authorization header containing the contents of the ${ENV_CMDS_AUTH_TOKEN} environment variable if defined`, + async () => { + const token = 'Basic abcd'; + process.env[ENV_CMDS_FULL_URI] = 'http://localhost:8080/path'; + process.env[ENV_CMDS_AUTH_TOKEN] = token; + mockHttpGet.mockReturnValue(Promise.resolve(JSON.stringify(creds))); + + await fromContainerMetadata()(); + + expect(mockHttpGet.mock.calls.length).toBe(1); + const [options = {}] = mockHttpGet.mock.calls[0]; + expect(options.headers).toMatchObject({ + Authorization: token, + }); + } + ); + + describe(ENV_CMDS_RELATIVE_URI, () => { + beforeEach(() => { + process.env[ENV_CMDS_RELATIVE_URI] = '/relative/uri'; + }); + + it( + 'should resolve credentials by fetching them from the container metadata service', + async () => { + mockHttpGet.mockReturnValue( + Promise.resolve(JSON.stringify(creds)) + ); + + expect(await fromContainerMetadata()()) + .toEqual(fromImdsCredentials(creds)); + } + ); + + it( + 'should retry the fetching operation up to maxRetries times', + async () => { + const maxRetries = 5; + for (let i = 0; i < maxRetries - 1; i++) { + mockHttpGet.mockReturnValueOnce(Promise.reject('No!')); + } + mockHttpGet.mockReturnValueOnce( + Promise.resolve(JSON.stringify(creds)) + ); + + expect(await fromContainerMetadata({maxRetries})()) + .toEqual(fromImdsCredentials(creds)); + expect(mockHttpGet.mock.calls.length).toEqual(maxRetries); + } + ); + + it( + 'should retry responses that receive invalid response values', + async () => { + for (let key of Object.keys(creds)) { + const invalidCreds: any = {...creds}; + delete invalidCreds[key]; + mockHttpGet.mockReturnValueOnce( + Promise.resolve(JSON.stringify(invalidCreds)) + ); + } + mockHttpGet.mockReturnValueOnce( + Promise.resolve(JSON.stringify(creds)) + ); + + await fromContainerMetadata({maxRetries: 100})(); + expect(mockHttpGet.mock.calls.length) + .toEqual(Object.keys(creds).length + 1); + } + ); + + it('should pass relevant configuration to httpGet', async () => { + const timeout = Math.ceil(Math.random() * 1000); + mockHttpGet.mockReturnValue(Promise.resolve(JSON.stringify(creds))); + await fromContainerMetadata({timeout})(); + expect(mockHttpGet.mock.calls.length).toEqual(1); + expect(mockHttpGet.mock.calls[0][0]).toEqual({ + hostname: '169.254.170.2', + path: process.env[ENV_CMDS_RELATIVE_URI], + timeout, + }); + }); + }); + + describe(ENV_CMDS_FULL_URI, () => { + it('should pass relevant configuration to httpGet', async () => { + process.env[ENV_CMDS_FULL_URI] = 'http://localhost:8080/path'; + + const timeout = Math.ceil(Math.random() * 1000); + mockHttpGet.mockReturnValue(Promise.resolve(JSON.stringify(creds))); + await fromContainerMetadata({timeout})(); + expect(mockHttpGet.mock.calls.length).toEqual(1); + const { + protocol, + hostname, + path, + port, + timeout: actualTimeout, + } = mockHttpGet.mock.calls[0][0]; + expect(protocol).toBe('http:'); + expect(hostname).toBe('localhost'); + expect(path).toBe('/path'); + expect(port).toBe(8080); + expect(actualTimeout).toBe(timeout); + }); + + it( + `should prefer ${ENV_CMDS_RELATIVE_URI} to ${ENV_CMDS_FULL_URI}`, + async () => { + process.env[ENV_CMDS_RELATIVE_URI] = 'foo'; + process.env[ENV_CMDS_FULL_URI] = 'http://localhost:8080/path'; + + const timeout = Math.ceil(Math.random() * 1000); + mockHttpGet.mockReturnValue( + Promise.resolve(JSON.stringify(creds)) + ); + await fromContainerMetadata({timeout})(); + expect(mockHttpGet.mock.calls.length).toEqual(1); + expect(mockHttpGet.mock.calls[0][0]).toEqual({ + hostname: '169.254.170.2', + path: 'foo', + timeout, + }); + } + ); + + it( + 'should reject the promise with a terminal error if a unexpected protocol is specified', + async () => { + process.env[ENV_CMDS_FULL_URI] = 'wss://localhost:8080/path'; + + await fromContainerMetadata()().then( + () => { + throw new Error('The promise should have been rejected'); + }, + err => { + expect((err as any).tryNextLink).toBeFalsy(); + } + ); + } + ); + + it( + 'should reject the promise with a terminal error if a unexpected hostname is specified', + async () => { + process.env[ENV_CMDS_FULL_URI] = 'https://bucket.s3.amazonaws.com/key'; + + await fromContainerMetadata()().then( + () => { + throw new Error('The promise should have been rejected'); + }, + err => { + expect((err as any).tryNextLink).toBeFalsy(); + } + ); + } + ); + }); +}); diff --git a/packages/credential-provider/__tests__/fromCredentials.ts b/packages/credential-provider/__tests__/fromCredentials.ts new file mode 100644 index 000000000000..00f141242e01 --- /dev/null +++ b/packages/credential-provider/__tests__/fromCredentials.ts @@ -0,0 +1,16 @@ +import {CredentialProvider, Credentials} from "@aws/types"; +import {fromCredentials} from "../lib/fromCredentials"; + +describe('fromCredentials', () => { + it('should convert credentials into a credential provider', async () => { + const credentials: Credentials = { + accessKeyId: 'foo', + secretAccessKey: 'bar' + }; + const provider: CredentialProvider = fromCredentials(credentials); + + expect(typeof provider).toBe('function'); + expect(provider()).toBeInstanceOf(Promise); + expect(await provider()).toEqual(credentials); + }); +}); diff --git a/packages/credential-provider/__tests__/fromEnv.ts b/packages/credential-provider/__tests__/fromEnv.ts new file mode 100644 index 000000000000..c33302fd8e9a --- /dev/null +++ b/packages/credential-provider/__tests__/fromEnv.ts @@ -0,0 +1,67 @@ +import { + ENV_KEY, + ENV_SECRET, + ENV_SESSION, + fromEnv, +} from "../lib/fromEnv"; +import {CredentialError} from "../lib/CredentialError"; + +const akid = process.env[ENV_KEY]; +const secret = process.env[ENV_SECRET]; +const token = process.env[ENV_SESSION]; + +beforeEach(() => { + delete process.env[ENV_KEY]; + delete process.env[ENV_SECRET]; + delete process.env[ENV_SESSION]; +}); + +afterAll(() => { + process.env[ENV_KEY] = akid; + process.env[ENV_SECRET] = secret; + process.env[ENV_SESSION] = token; +}); + +describe('fromEnv', () => { + it('should read credentials from known environment variables', async () => { + process.env[ENV_KEY] = 'foo'; + process.env[ENV_SECRET] = 'bar'; + process.env[ENV_SESSION] = 'baz'; + + expect(await fromEnv()()).toEqual({ + accessKeyId: 'foo', + secretAccessKey: 'bar', + sessionToken: 'baz', + }); + }); + + it('can create credentials without a session token', async () => { + process.env[ENV_KEY] = 'foo'; + process.env[ENV_SECRET] = 'bar'; + + expect(await fromEnv()()).toEqual({ + accessKeyId: 'foo', + secretAccessKey: 'bar', + sessionToken: void 0, + }); + }); + + it( + 'should reject the promise if no environmental credentials can be found', + async () => { + await fromEnv()().then( + () => { throw new Error('The promise should have been rejected.'); }, + () => { /* Promise rejected as expected */ } + ); + } + ); + + it('should flag a lack of credentials as a non-terminal error', async () => { + await fromEnv()().then( + () => { throw new Error('The promise should have been rejected.'); }, + err => { + expect((err as CredentialError).tryNextLink).toBe(true); + } + ); + }); +}); diff --git a/packages/credential-provider/__tests__/fromIni.ts b/packages/credential-provider/__tests__/fromIni.ts new file mode 100644 index 000000000000..1c985cea90c7 --- /dev/null +++ b/packages/credential-provider/__tests__/fromIni.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 "../lib/fromIni"; +import {CredentialError} from "../lib/CredentialError"; + +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/__tests__/fromInstanceMetadata.ts b/packages/credential-provider/__tests__/fromInstanceMetadata.ts new file mode 100644 index 000000000000..83e1e7defa46 --- /dev/null +++ b/packages/credential-provider/__tests__/fromInstanceMetadata.ts @@ -0,0 +1,122 @@ +import {fromInstanceMetadata} from "../lib/fromInstanceMetadata"; +import {httpGet} from "../lib/remoteProvider/httpGet"; +import { + fromImdsCredentials, + ImdsCredentials +} from "../lib/remoteProvider/ImdsCredentials"; +import MockInstance = jest.MockInstance; +import {RequestOptions} from "http"; + +interface HttpGet { + (options: RequestOptions): Promise; +} + +const mockHttpGet = >httpGet; +jest.mock('../lib/remoteProvider/httpGet', () => ({httpGet: jest.fn()})); + +beforeEach(() => { + mockHttpGet.mockReset(); +}); + +describe('fromInstanceMetadata', () => { + const creds: ImdsCredentials = Object.freeze({ + AccessKeyId: 'foo', + SecretAccessKey: 'bar', + Token: 'baz', + Expiration: new Date().toISOString(), + }); + + it( + 'should resolve credentials by fetching them from the container metadata service', + async () => { + mockHttpGet.mockReturnValue(Promise.resolve(JSON.stringify(creds))); + expect(await fromInstanceMetadata({profile: 'foo'})()) + .toEqual(fromImdsCredentials(creds)); + } + ); + + it('should retry the fetching operation up to maxRetries times', async () => { + const maxRetries = 5; + for (let i = 0; i < maxRetries - 1; i++) { + mockHttpGet.mockReturnValueOnce(Promise.reject('No!')); + } + mockHttpGet.mockReturnValueOnce( + Promise.resolve(JSON.stringify(creds)) + ); + + expect(await fromInstanceMetadata({maxRetries, profile: 'foo'})()) + .toEqual(fromImdsCredentials(creds)); + expect(mockHttpGet.mock.calls.length).toEqual(maxRetries); + }); + + it('should retry responses that receive invalid response values', async () => { + for (let key of Object.keys(creds)) { + const invalidCreds: any = {...creds}; + delete invalidCreds[key]; + mockHttpGet.mockReturnValueOnce( + Promise.resolve(JSON.stringify(invalidCreds)) + ); + } + mockHttpGet.mockReturnValueOnce(Promise.resolve(JSON.stringify(creds))); + + await fromInstanceMetadata({maxRetries: 100, profile: 'foo'})(); + expect(mockHttpGet.mock.calls.length) + .toEqual(Object.keys(creds).length + 1); + }); + + it('should pass relevant configuration to httpGet', async () => { + const timeout = Math.ceil(Math.random() * 1000); + const profile = 'foo-profile'; + mockHttpGet.mockReturnValue(Promise.resolve(JSON.stringify(creds))); + await fromInstanceMetadata({timeout, profile})(); + expect(mockHttpGet.mock.calls.length).toEqual(1); + expect(mockHttpGet.mock.calls[0][0]).toEqual({ + host: '169.254.169.254', + path: `/latest/meta-data/iam/security-credentials/${profile}`, + timeout, + }); + }); + + it('should fetch the profile name if not supplied', async () => { + const defaultTimeout = 1000; + const profile = 'foo-profile'; + mockHttpGet.mockReturnValueOnce(Promise.resolve(profile)); + mockHttpGet.mockReturnValueOnce(Promise.resolve(JSON.stringify(creds))); + + await fromInstanceMetadata()(); + expect(mockHttpGet.mock.calls.length).toEqual(2); + expect(mockHttpGet.mock.calls[0][0]).toEqual({ + host: '169.254.169.254', + path: '/latest/meta-data/iam/security-credentials/', + timeout: defaultTimeout, + }); + expect(mockHttpGet.mock.calls[1][0]).toEqual({ + host: '169.254.169.254', + path: `/latest/meta-data/iam/security-credentials/${profile}`, + timeout: defaultTimeout, + }); + }); + + it('should retry the profile name fetch as necessary', async () => { + const defaultTimeout = 1000; + const profile = 'foo-profile'; + mockHttpGet.mockReturnValueOnce(Promise.reject('Too busy')); + mockHttpGet.mockReturnValueOnce(Promise.resolve(profile)); + mockHttpGet.mockReturnValueOnce(Promise.resolve(JSON.stringify(creds))); + + await fromInstanceMetadata()(); + expect(mockHttpGet.mock.calls.length).toEqual(3); + expect(mockHttpGet.mock.calls[2][0]).toEqual({ + host: '169.254.169.254', + path: `/latest/meta-data/iam/security-credentials/${profile}`, + timeout: defaultTimeout, + }); + for (let index of [0, 1]) { + expect(mockHttpGet.mock.calls[index][0]).toEqual({ + host: '169.254.169.254', + path: '/latest/meta-data/iam/security-credentials/', + timeout: defaultTimeout, + }); + } + }); +}); diff --git a/packages/credential-provider/__tests__/isCredentials.ts b/packages/credential-provider/__tests__/isCredentials.ts new file mode 100644 index 000000000000..bb6639fb8cc5 --- /dev/null +++ b/packages/credential-provider/__tests__/isCredentials.ts @@ -0,0 +1,51 @@ +import {isCredentials} from "../lib/isCredentials"; + +describe('isCredentials', () => { + const minimalCredentials = {accessKeyId: 'foo', secretAccessKey: 'bar'}; + + it('should reject scalar values', () => { + for (let scalar of ['foo', 12, 1.2, true, null, undefined]) { + expect(isCredentials(scalar)).toBe(false); + } + }); + + it('should accept an object with an accessKeyId and secretAccessKey', () => { + expect(isCredentials(minimalCredentials)).toBe(true); + }); + + it('should reject objects where accessKeyId is not a string', () => { + expect(isCredentials( + Object.assign({}, minimalCredentials, {accessKeyId: 123}) + )).toBe(false); + }); + + it('should reject objects where secretAccessKey is not a string', () => { + expect(isCredentials( + Object.assign({}, minimalCredentials, {secretAccessKey: 123}) + )).toBe(false); + }); + + it('should accept credentials with a sessionToken', () => { + expect(isCredentials( + Object.assign({sessionToken: 'baz'}, minimalCredentials) + )).toBe(true); + }); + + it('should reject credentials where sessionToken is not a string', () => { + expect(isCredentials( + Object.assign({sessionToken: 123}, minimalCredentials) + )).toBe(false); + }); + + it('should accept credentials with an expiration', () => { + expect(isCredentials( + Object.assign({expiration: 0}, minimalCredentials) + )).toBe(true); + }); + + it('should reject credentials where expiration is not a number', () => { + expect(isCredentials( + Object.assign({expiration: 'quux'}, minimalCredentials) + )).toBe(false); + }); +}); diff --git a/packages/credential-provider/__tests__/memoize.ts b/packages/credential-provider/__tests__/memoize.ts new file mode 100644 index 000000000000..59af710bc77d --- /dev/null +++ b/packages/credential-provider/__tests__/memoize.ts @@ -0,0 +1,38 @@ +import {memoize} from "../lib/memoize"; + +describe('memoize', () => { + it('should cache the resolved provider for permanent credentials', async () => { + const creds = {accessKeyId: 'foo', secretAccessKey: 'bar'}; + const provider = jest.fn(() => Promise.resolve(creds)); + const memoized = memoize(provider); + + expect(await memoized()).toEqual(creds); + expect(provider.mock.calls.length).toBe(1); + expect(await memoized()).toEqual(creds); + expect(provider.mock.calls.length).toBe(1); + }); + + it('should invoke provider again when credentials expire', async () => { + const clockMock = Date.now = jest.fn(); + clockMock.mockReturnValue(0); + const provider = jest.fn(() => Promise.resolve({ + accessKeyId: 'foo', + secretAccessKey: 'bar', + expiration: Date.now() + 600, // expires in ten minutes + })); + const memoized = memoize(provider); + + expect((await memoized()).accessKeyId).toEqual('foo'); + expect(provider.mock.calls.length).toBe(1); + expect((await memoized()).secretAccessKey).toEqual('bar'); + expect(provider.mock.calls.length).toBe(1); + + clockMock.mockReset(); + clockMock.mockReturnValue(601000); // One second past previous expiration + + expect((await memoized()).accessKeyId).toEqual('foo'); + expect(provider.mock.calls.length).toBe(2); + expect((await memoized()).secretAccessKey).toEqual('bar'); + expect(provider.mock.calls.length).toBe(2); + }); +}); diff --git a/packages/credential-provider/__tests__/remoteProvider/ImdsCredentials.ts b/packages/credential-provider/__tests__/remoteProvider/ImdsCredentials.ts new file mode 100644 index 000000000000..3024af9985d2 --- /dev/null +++ b/packages/credential-provider/__tests__/remoteProvider/ImdsCredentials.ts @@ -0,0 +1,61 @@ +import { + fromImdsCredentials, + ImdsCredentials, + isImdsCredentials, +} from "../../lib/remoteProvider/ImdsCredentials"; +import {Credentials} from "@aws/types"; + +const creds: ImdsCredentials = Object.freeze({ + AccessKeyId: 'foo', + SecretAccessKey: 'bar', + Token: 'baz', + Expiration: new Date().toISOString(), +}); + +describe('isImdsCredentials', () => { + it('should accept valid ImdsCredentials objects', () => { + expect(isImdsCredentials(creds)).toBe(true); + }); + + it('should reject credentials without an AccessKeyId', () => { + expect( + isImdsCredentials(Object.assign({}, creds, {AccessKeyId: void 0})) + ).toBe(false); + }); + + it('should reject credentials without a SecretAccessKey', () => { + expect( + isImdsCredentials(Object.assign({}, creds, {SecretAccessKey: void 0})) + ).toBe(false); + }); + + it('should reject credentials without a Token', () => { + expect( + isImdsCredentials(Object.assign({}, creds, {Token: void 0})) + ).toBe(false); + }); + + it('should reject credentials without an Expiration', () => { + expect( + isImdsCredentials(Object.assign({}, creds, {Expiration: void 0})) + ).toBe(false); + }); + + it('should reject scalar values', () => { + for (let scalar of ['string', 1, true, null, void 0]) { + expect(isImdsCredentials(scalar)).toBe(false); + } + }); +}); + +describe('fromImdsCredentials', () => { + it('should convert IMDS credentials to a credentials object', () => { + const converted: Credentials = fromImdsCredentials(creds); + expect(converted.accessKeyId).toEqual(creds.AccessKeyId); + expect(converted.secretAccessKey).toEqual(creds.SecretAccessKey); + expect(converted.sessionToken).toEqual(creds.Token); + expect(converted.expiration).toEqual( + Math.floor((new Date(creds.Expiration).valueOf()) / 1000) + ); + }); +}); diff --git a/packages/credential-provider/__tests__/remoteProvider/RemoteProviderInit.ts b/packages/credential-provider/__tests__/remoteProvider/RemoteProviderInit.ts new file mode 100644 index 000000000000..59f622b8daeb --- /dev/null +++ b/packages/credential-provider/__tests__/remoteProvider/RemoteProviderInit.ts @@ -0,0 +1,22 @@ +import { + DEFAULT_MAX_RETRIES, + DEFAULT_TIMEOUT, + providerConfigFromInit, +} from "../../lib/remoteProvider/RemoteProviderInit"; + +describe('providerConfigFromInit', () => { + it('should populate default values for retries and timeouts', () => { + expect(providerConfigFromInit({})).toEqual({ + timeout: DEFAULT_TIMEOUT, + maxRetries: DEFAULT_MAX_RETRIES, + }); + }); + + it('should pass through timeout and retries overrides', () => { + const timeout = 123456789; + const maxRetries = 987654321; + + expect(providerConfigFromInit({timeout, maxRetries})) + .toEqual({timeout, maxRetries}); + }); +}); diff --git a/packages/credential-provider/__tests__/remoteProvider/httpGet.ts b/packages/credential-provider/__tests__/remoteProvider/httpGet.ts new file mode 100644 index 000000000000..238bb701745b --- /dev/null +++ b/packages/credential-provider/__tests__/remoteProvider/httpGet.ts @@ -0,0 +1,92 @@ +import {createServer} from 'http'; +import {httpGet} from "../../lib/remoteProvider/httpGet"; +import {CredentialError} from "../../lib/CredentialError"; + +const matchers = new Map(); + +function addMatcher(url: string, toReturn: string): void { + matchers.set(url, toReturn); +} + +function clearMatchers(): void { + matchers.clear(); +} + +function getOpenPort(candidatePort: number = 4321): Promise { + return new Promise((resolve, reject) => { + const server = createServer(); + server.on('error', () => reject()); + server.listen(candidatePort); + server.close(() => resolve(candidatePort)); + }) + .catch(() => getOpenPort(candidatePort + 1)); +} + +let port: number; + +const server = createServer((request, response) => { + const {url = ''} = request; + if (matchers.has(url)) { + response.statusCode = 200; + response.end(matchers.get(url)); + } else { + response.statusCode = 404; + response.end('Not found'); + } +}); + +beforeAll(async (done) => { + port = await getOpenPort(); + server.listen(port); + done(); +}); + +afterAll(() => { + server.close(); +}); + +beforeEach(clearMatchers); + +describe('httpGet', () => { + it('should respond with a promise fulfilled with the http response', async () => { + const expectedResponse = 'foo bar baz'; + addMatcher('/', expectedResponse); + + expect((await httpGet(`http://localhost:${port}/`)).toString('utf8')) + .toEqual(expectedResponse); + }); + + it( + 'should reject the promise with a non-terminal error if a 404 status code is received', + async () => { + addMatcher('/fizz', 'buzz'); + + await httpGet(`http://localhost:${port}/foo`).then( + () => { + throw new Error('The promise should have been rejected'); + }, + err => { + expect((err as CredentialError).tryNextLink).toBe(true); + } + ); + } + ); + + it( + 'should reject the promise with a non-terminal error if the remote server cannot be contacted', + async () => { + server.close(); + + await httpGet(`http://localhost:${port}/foo`).then( + () => { + throw new Error('The promise should have been rejected'); + }, + err => { + expect((err as CredentialError).tryNextLink).toBe(true); + } + ); + } + ); +}); + + diff --git a/packages/credential-provider/__tests__/remoteProvider/retry.ts b/packages/credential-provider/__tests__/remoteProvider/retry.ts new file mode 100644 index 000000000000..45f00552d00f --- /dev/null +++ b/packages/credential-provider/__tests__/remoteProvider/retry.ts @@ -0,0 +1,31 @@ +import {retry} from "../../lib/remoteProvider/retry"; + +describe('retry', () => { + it('should retry a function the specified number of times', async () => { + const retries = 10; + const retryable = jest.fn(() => Promise.reject('Expected failure')); + + await retry(retryable, retries) + .catch(msg => { + expect(retryable.mock.calls.length).toEqual(retries + 1); + expect(msg).toEqual('Expected failure'); + }); + }); + + it('should stop retrying after the first successful invocation', async () => { + const retries = 10; + const successfulInvocationIndex = 3; + let invocations = 0; + const retryable = jest.fn(() => { + if (++invocations === successfulInvocationIndex) { + return Promise.resolve('Success!'); + } + return Promise.reject('Expected failure'); + }); + + await retry(retryable, retries).then(() => { + expect(retryable.mock.calls.length) + .toEqual(successfulInvocationIndex); + }); + }); +}); diff --git a/packages/credential-provider/index.ts b/packages/credential-provider/index.ts new file mode 100755 index 000000000000..728cb930c9a8 --- /dev/null +++ b/packages/credential-provider/index.ts @@ -0,0 +1,9 @@ +export * from './lib/chain'; +export * from './lib/fromCredentials'; +export * from './lib/fromEnv'; +export * from './lib/fromIni'; +export * from './lib/isCredentials'; +export * from './lib/memoize'; +export * from './lib/remoteProvider'; +export * from './lib/fromContainerMetadata'; +export * from './lib/fromInstanceMetadata'; diff --git a/packages/credential-provider/lib/CredentialError.ts b/packages/credential-provider/lib/CredentialError.ts new file mode 100644 index 000000000000..b9adefe794cc --- /dev/null +++ b/packages/credential-provider/lib/CredentialError.ts @@ -0,0 +1,5 @@ +export class CredentialError extends Error { + constructor(message: string, public readonly tryNextLink: boolean = true) { + super(message); + } +} diff --git a/packages/credential-provider/lib/chain.ts b/packages/credential-provider/lib/chain.ts new file mode 100755 index 000000000000..9016c78a8ad4 --- /dev/null +++ b/packages/credential-provider/lib/chain.ts @@ -0,0 +1,30 @@ +import {CredentialProvider} from "@aws/types"; +import {CredentialError} from "./CredentialError"; + +export function chain( + ...providers: Array +): CredentialProvider { + return () => { + providers = providers.slice(0); + let provider = providers.shift(); + if (provider === undefined) { + return Promise.reject(new CredentialError( + 'No credential providers in chain' + )); + } + let promise = provider(); + while (provider = providers.shift()) { + promise = promise.catch((provider => { + return (err: CredentialError) => { + if (err.tryNextLink) { + return provider(); + } + + throw err; + } + })(provider)); + } + + return promise; + } +} diff --git a/packages/credential-provider/lib/fromContainerMetadata.ts b/packages/credential-provider/lib/fromContainerMetadata.ts new file mode 100644 index 000000000000..cc5ea6d7fce0 --- /dev/null +++ b/packages/credential-provider/lib/fromContainerMetadata.ts @@ -0,0 +1,103 @@ +import {CredentialProvider} from "@aws/types"; +import { + RemoteProviderInit, + providerConfigFromInit, +} from './remoteProvider/RemoteProviderInit'; +import {httpGet} from './remoteProvider/httpGet'; +import { + fromImdsCredentials, + isImdsCredentials, +} from './remoteProvider/ImdsCredentials'; +import {retry} from './remoteProvider/retry'; +import {CredentialError} from "./CredentialError"; +import {parse} from "url"; +import {RequestOptions} from "http"; + +export const ENV_CMDS_FULL_URI = 'AWS_CONTAINER_CREDENTIALS_FULL_URI'; +export const ENV_CMDS_RELATIVE_URI = 'AWS_CONTAINER_CREDENTIALS_RELATIVE_URI'; +export const ENV_CMDS_AUTH_TOKEN = 'AWS_CONTAINER_AUTHORIZATION_TOKEN'; + +export function fromContainerMetadata( + init: RemoteProviderInit = {} +): CredentialProvider { + const {timeout, maxRetries} = providerConfigFromInit(init); + return () => { + return getCmdsUri().then(url => retry(async () => { + const credsResponse = JSON.parse( + await requestFromEcsImds(timeout, url) + ); + if (!isImdsCredentials(credsResponse)) { + throw new CredentialError( + 'Invalid response received from instance metadata service.' + ); + } + + return fromImdsCredentials(credsResponse); + }, maxRetries)); + } +} + +function requestFromEcsImds( + timeout: number, + options: RequestOptions +): Promise { + if (process.env[ENV_CMDS_AUTH_TOKEN]) { + const {headers = {}} = options; + headers.Authorization = process.env[ENV_CMDS_AUTH_TOKEN]; + options.headers = headers; + } + + return httpGet({ + ...options, + timeout, + }) + .then(buffer => buffer.toString()); +} + +const CMDS_IP = '169.254.170.2'; +const GREENGRASS_HOSTS = new Set([ + 'localhost', + '127.0.0.1', +]); +const GREENGRASS_PROTOCOLS = new Set([ + 'http:', + 'https:', +]); + +function getCmdsUri(): Promise { + if (process.env[ENV_CMDS_RELATIVE_URI]) { + return Promise.resolve({ + hostname: CMDS_IP, + path: process.env[ENV_CMDS_RELATIVE_URI], + }); + } + + if (process.env[ENV_CMDS_FULL_URI]) { + const parsed = parse(process.env[ENV_CMDS_FULL_URI]); + if (!parsed.hostname || !GREENGRASS_HOSTS.has(parsed.hostname)) { + return Promise.reject(new CredentialError( + `${parsed.hostname} is not a valid container metadata service hostname`, + false + )); + } + + if (!parsed.protocol || !GREENGRASS_PROTOCOLS.has(parsed.protocol)) { + return Promise.reject(new CredentialError( + `${parsed.protocol} is not a valid container metadata service protocol`, + false + )); + } + + return Promise.resolve({ + ...parsed, + port: parsed.port ? Number.parseInt(parsed.port, 10) : undefined + }); + } + + return Promise.reject(new CredentialError( + 'The container metadata credential provider cannot be used unless' + + ` the ${ENV_CMDS_RELATIVE_URI} or ${ENV_CMDS_FULL_URI} environment` + + ' variable is set', + false + )); +} diff --git a/packages/credential-provider/lib/fromCredentials.ts b/packages/credential-provider/lib/fromCredentials.ts new file mode 100755 index 000000000000..a9f8e8922e73 --- /dev/null +++ b/packages/credential-provider/lib/fromCredentials.ts @@ -0,0 +1,7 @@ +import {CredentialProvider, Credentials} from "@aws/types"; + +export function fromCredentials( + credentials: Credentials +): CredentialProvider { + return () => Promise.resolve(credentials); +} diff --git a/packages/credential-provider/lib/fromEnv.ts b/packages/credential-provider/lib/fromEnv.ts new file mode 100755 index 000000000000..43d4db3679f5 --- /dev/null +++ b/packages/credential-provider/lib/fromEnv.ts @@ -0,0 +1,24 @@ +import {CredentialProvider} from "@aws/types"; +import {CredentialError} from "./CredentialError"; + +export const ENV_KEY = 'AWS_ACCESS_KEY_ID'; +export const ENV_SECRET = 'AWS_SECRET_ACCESS_KEY'; +export const ENV_SESSION = 'AWS_SESSION_TOKEN'; + +export function fromEnv(): CredentialProvider { + return () => { + const accessKeyId: string = process.env[ENV_KEY]; + const secretAccessKey: string = process.env[ENV_SECRET]; + if (accessKeyId && secretAccessKey) { + return Promise.resolve({ + accessKeyId, + secretAccessKey, + sessionToken: process.env[ENV_SESSION], + }); + } + + return Promise.reject(new CredentialError( + 'Unable to find environment variable credentials.' + )); + }; +} diff --git a/packages/credential-provider/lib/fromIni.ts b/packages/credential-provider/lib/fromIni.ts new file mode 100755 index 000000000000..8880a0258475 --- /dev/null +++ b/packages/credential-provider/lib/fromIni.ts @@ -0,0 +1,206 @@ +import {CredentialProvider, Credentials} from '@aws/types'; +import {homedir} from 'os'; +import {join, sep} from 'path'; +import {readFile} from 'fs'; +import {CredentialError} from "./CredentialError"; + +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'; + +export interface AssumeRoleParams { + RoleArn: string; + RoleSessionName: string; + ExternalId?: string; + SerialNumber?: string; + TokenCode?: string; +} + +export interface FromIniInit { + profile?: string; + filepath?: string; + configFilepath?: string; + mfaCodeProvider?: (mfaSerial: string) => Promise; + 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; +} + +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/lib/fromInstanceMetadata.ts b/packages/credential-provider/lib/fromInstanceMetadata.ts new file mode 100644 index 000000000000..d62e8e1c2b76 --- /dev/null +++ b/packages/credential-provider/lib/fromInstanceMetadata.ts @@ -0,0 +1,51 @@ +import {CredentialProvider} from "@aws/types"; +import { + Ec2InstanceMetadataInit, + providerConfigFromInit, +} from './remoteProvider/RemoteProviderInit'; +import {httpGet} from './remoteProvider/httpGet'; +import { + fromImdsCredentials, + isImdsCredentials, +} from './remoteProvider/ImdsCredentials'; +import {retry} from './remoteProvider/retry'; +import {CredentialError} from "./CredentialError"; + +export function fromInstanceMetadata( + init: Ec2InstanceMetadataInit = {} +): CredentialProvider { + const {timeout, maxRetries} = providerConfigFromInit(init); + return async () => { + const { + profile = (await retry( + async () => await requestFromEc2Imds(timeout), + maxRetries + )).trim() + } = init; + + return await retry(async () => { + const credsResponse = JSON.parse( + await requestFromEc2Imds(timeout, profile) + ); + if (!isImdsCredentials(credsResponse)) { + throw new CredentialError( + 'Invalid response received from instance metadata service.' + ); + } + + return fromImdsCredentials(credsResponse); + }, maxRetries); + }; +} + +const IMDS_IP = '169.254.169.254'; +const IMDS_PATH = 'latest/meta-data/iam/security-credentials'; + +function requestFromEc2Imds(timeout: number, path?: string): Promise { + return httpGet({ + host: IMDS_IP, + path: `/${IMDS_PATH}/${path ? path : ''}`, + timeout, + }) + .then(buffer => buffer.toString()); +} diff --git a/packages/credential-provider/lib/isCredentials.ts b/packages/credential-provider/lib/isCredentials.ts new file mode 100644 index 000000000000..9d5f850a81ec --- /dev/null +++ b/packages/credential-provider/lib/isCredentials.ts @@ -0,0 +1,10 @@ +import {Credentials} from '@aws/types'; + +export function isCredentials(arg: any): arg is Credentials { + return typeof arg === 'object' + && arg !== null + && typeof arg.accessKeyId === 'string' + && typeof arg.secretAccessKey === 'string' + && ['string', 'undefined'].indexOf(typeof arg.sessionToken) > -1 + && ['number', 'undefined'].indexOf(typeof arg.expiration) > -1; +} diff --git a/packages/credential-provider/lib/memoize.ts b/packages/credential-provider/lib/memoize.ts new file mode 100755 index 000000000000..f6d0751c40a7 --- /dev/null +++ b/packages/credential-provider/lib/memoize.ts @@ -0,0 +1,29 @@ +import {CredentialProvider} from "@aws/types"; + +export function memoize(provider: CredentialProvider): CredentialProvider { + let result= provider(); + let isConstant: boolean = false; + + return () => { + if (isConstant) { + return result; + } + + return result.then(credentials => { + if (!credentials.expiration) { + isConstant = true; + return credentials; + } + + if (credentials.expiration - 300 > getEpochTs()) { + return credentials; + } + + return result = provider(); + }); + } +} + +function getEpochTs() { + return Math.floor(Date.now() / 1000); +} diff --git a/packages/credential-provider/lib/remoteProvider/ImdsCredentials.ts b/packages/credential-provider/lib/remoteProvider/ImdsCredentials.ts new file mode 100644 index 000000000000..e49d6c71ee1c --- /dev/null +++ b/packages/credential-provider/lib/remoteProvider/ImdsCredentials.ts @@ -0,0 +1,27 @@ +import {Credentials} from "@aws/types"; + +export interface ImdsCredentials { + AccessKeyId: string; + SecretAccessKey: string; + Token: string; + Expiration: string; +} + +export function isImdsCredentials(arg: any): arg is ImdsCredentials { + return Boolean(arg) && typeof arg === 'object' + && typeof arg.AccessKeyId === 'string' + && typeof arg.SecretAccessKey === 'string' + && typeof arg.Token === 'string' + && typeof arg.Expiration === 'string'; +} + +export function fromImdsCredentials(creds: ImdsCredentials): Credentials { + return { + accessKeyId: creds.AccessKeyId, + secretAccessKey: creds.SecretAccessKey, + sessionToken: creds.Token, + expiration: Math.floor( + (new Date(creds.Expiration)).valueOf() / 1000 + ), + } +} diff --git a/packages/credential-provider/lib/remoteProvider/RemoteProviderInit.ts b/packages/credential-provider/lib/remoteProvider/RemoteProviderInit.ts new file mode 100644 index 000000000000..4905745bfbc3 --- /dev/null +++ b/packages/credential-provider/lib/remoteProvider/RemoteProviderInit.ts @@ -0,0 +1,24 @@ +export const DEFAULT_TIMEOUT = 1000; +export const DEFAULT_MAX_RETRIES = 3; + +export interface RemoteProviderConfig { + timeout: number; + maxRetries: number; +} + +export type RemoteProviderInit = Partial; + +export interface Ec2InstanceMetadataInit extends RemoteProviderInit { + profile?: string; +} + +export function providerConfigFromInit( + init: RemoteProviderInit +): RemoteProviderConfig { + const { + timeout = DEFAULT_TIMEOUT, + maxRetries = DEFAULT_MAX_RETRIES, + } = init; + + return {maxRetries, timeout}; +} diff --git a/packages/credential-provider/lib/remoteProvider/httpGet.ts b/packages/credential-provider/lib/remoteProvider/httpGet.ts new file mode 100644 index 000000000000..06168808901a --- /dev/null +++ b/packages/credential-provider/lib/remoteProvider/httpGet.ts @@ -0,0 +1,31 @@ +import {Buffer} from 'buffer'; +import {get, IncomingMessage, RequestOptions} from 'http'; +import {CredentialError} from "../CredentialError"; + +export function httpGet(options: RequestOptions): Promise { + return new Promise((resolve, reject) => { + const request = get(options); + request.on('error', err => { + reject(new CredentialError( + 'Unable to connect to instance metadata service' + )); + }); + + request.on('response', (res: IncomingMessage) => { + const {statusCode = 400} = res; + if (statusCode < 200 || 300 <= statusCode) { + reject(new CredentialError( + 'Error response received from instance metadata service' + )); + } + + const chunks: Array = []; + res.on('readable', () => { + chunks.push(res.read()); + }); + res.on('end', () => { + resolve(Buffer.concat(chunks)); + }); + }); + }); +} diff --git a/packages/credential-provider/lib/remoteProvider/index.ts b/packages/credential-provider/lib/remoteProvider/index.ts new file mode 100644 index 000000000000..96f48701f9b1 --- /dev/null +++ b/packages/credential-provider/lib/remoteProvider/index.ts @@ -0,0 +1,2 @@ +export * from './ImdsCredentials'; +export * from './RemoteProviderInit'; diff --git a/packages/credential-provider/lib/remoteProvider/retry.ts b/packages/credential-provider/lib/remoteProvider/retry.ts new file mode 100644 index 000000000000..ace65509f0c0 --- /dev/null +++ b/packages/credential-provider/lib/remoteProvider/retry.ts @@ -0,0 +1,15 @@ +export interface RetryableProvider { + (): Promise; +} + +export function retry( + toRetry: RetryableProvider, + maxRetries: number +): Promise { + let promise = toRetry(); + for (let i = 0; i < maxRetries; i++) { + promise = promise.catch(toRetry); + } + + return promise; +} diff --git a/packages/credential-provider/package.json b/packages/credential-provider/package.json new file mode 100755 index 000000000000..72361db94e39 --- /dev/null +++ b/packages/credential-provider/package.json @@ -0,0 +1,28 @@ +{ + "name": "@aws/credentials", + "version": "0.0.1", + "private": true, + "description": "AWS credential provider for node", + "main": "index.js", + "scripts": { + "prepublishOnly": "tsc", + "pretest": "tsc", + "test": "jest" + }, + "keywords": [ + "aws", + "credentials" + ], + "author": "aws-javascript-sdk-team@amazon.com", + "license": "UNLICENSED", + "dependencies": { + "@aws/types": "^0.0.1", + "tslib": "^1.7.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/tsconfig.json b/packages/credential-provider/tsconfig.json new file mode 100755 index 000000000000..8ddd607d6f39 --- /dev/null +++ b/packages/credential-provider/tsconfig.json @@ -0,0 +1,9 @@ +{ + "compilerOptions": { + "module": "commonjs", + "target": "es6", + "importHelpers": true, + "strict": true, + "sourceMap": true + } +}