From 395a6eb1d9e0ff65bee03fed078d21f9031b4f95 Mon Sep 17 00:00:00 2001 From: Jonathan Eskew Date: Tue, 13 Jun 2017 12:23:59 -0700 Subject: [PATCH 1/2] Add a base credential provider package --- packages/credential-provider-base/.gitignore | 4 ++ .../__tests__/CredentialError.ts | 11 +++ .../__tests__/chain.ts | 70 +++++++++++++++++++ .../__tests__/fromCredentials.ts | 16 +++++ .../__tests__/isCredentials.ts | 57 +++++++++++++++ .../__tests__/memoize.ts | 38 ++++++++++ packages/credential-provider-base/index.ts | 5 ++ .../lib/CredentialError.ts | 10 +++ .../credential-provider-base/lib/chain.ts | 39 +++++++++++ .../lib/fromCredentials.ts | 10 +++ .../lib/isCredentials.ts | 14 ++++ .../credential-provider-base/lib/memoize.ts | 36 ++++++++++ .../credential-provider-base/package.json | 26 +++++++ .../credential-provider-base/tsconfig.json | 13 ++++ 14 files changed, 349 insertions(+) create mode 100644 packages/credential-provider-base/.gitignore create mode 100644 packages/credential-provider-base/__tests__/CredentialError.ts create mode 100644 packages/credential-provider-base/__tests__/chain.ts create mode 100644 packages/credential-provider-base/__tests__/fromCredentials.ts create mode 100644 packages/credential-provider-base/__tests__/isCredentials.ts create mode 100644 packages/credential-provider-base/__tests__/memoize.ts create mode 100644 packages/credential-provider-base/index.ts create mode 100644 packages/credential-provider-base/lib/CredentialError.ts create mode 100755 packages/credential-provider-base/lib/chain.ts create mode 100755 packages/credential-provider-base/lib/fromCredentials.ts create mode 100644 packages/credential-provider-base/lib/isCredentials.ts create mode 100755 packages/credential-provider-base/lib/memoize.ts create mode 100755 packages/credential-provider-base/package.json create mode 100755 packages/credential-provider-base/tsconfig.json diff --git a/packages/credential-provider-base/.gitignore b/packages/credential-provider-base/.gitignore new file mode 100644 index 0000000000000..b5d6f1c7b0fa5 --- /dev/null +++ b/packages/credential-provider-base/.gitignore @@ -0,0 +1,4 @@ +/node_modules/ +*.js +*.js.map +*.d.ts diff --git a/packages/credential-provider-base/__tests__/CredentialError.ts b/packages/credential-provider-base/__tests__/CredentialError.ts new file mode 100644 index 0000000000000..7cffa416f6c97 --- /dev/null +++ b/packages/credential-provider-base/__tests__/CredentialError.ts @@ -0,0 +1,11 @@ +import {CredentialError} from "../lib/CredentialError"; + +describe('CredentialError', () => { + it('should direct the chain to proceed to the next link by default', () => { + expect(new CredentialError('PANIC').tryNextLink).toBe(true); + }); + + it('should allow errors to halt the chain', () => { + expect(new CredentialError('PANIC', false).tryNextLink).toBe(false); + }); +}); diff --git a/packages/credential-provider-base/__tests__/chain.ts b/packages/credential-provider-base/__tests__/chain.ts new file mode 100644 index 0000000000000..d2a7c861e9978 --- /dev/null +++ b/packages/credential-provider-base/__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-base/__tests__/fromCredentials.ts b/packages/credential-provider-base/__tests__/fromCredentials.ts new file mode 100644 index 0000000000000..00f141242e01f --- /dev/null +++ b/packages/credential-provider-base/__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-base/__tests__/isCredentials.ts b/packages/credential-provider-base/__tests__/isCredentials.ts new file mode 100644 index 0000000000000..45f4806a5511a --- /dev/null +++ b/packages/credential-provider-base/__tests__/isCredentials.ts @@ -0,0 +1,57 @@ +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({ + ...minimalCredentials, + accessKeyId: 123, + })).toBe(false); + }); + + it('should reject objects where secretAccessKey is not a string', () => { + expect(isCredentials({ + ...minimalCredentials, + secretAccessKey: 123, + })).toBe(false); + }); + + it('should accept credentials with a sessionToken', () => { + expect(isCredentials({ + ...minimalCredentials, + sessionToken: 'baz', + })).toBe(true); + }); + + it('should reject credentials where sessionToken is not a string', () => { + expect(isCredentials({ + ...minimalCredentials, + sessionToken: 123, + })).toBe(false); + }); + + it('should accept credentials with an expiration', () => { + expect(isCredentials({ + ...minimalCredentials, + expiration: 0, + })).toBe(true); + }); + + it('should reject credentials where expiration is not a number', () => { + expect(isCredentials({ + ...minimalCredentials, + expiration: 'quux', + })).toBe(false); + }); +}); diff --git a/packages/credential-provider-base/__tests__/memoize.ts b/packages/credential-provider-base/__tests__/memoize.ts new file mode 100644 index 0000000000000..59af710bc77dd --- /dev/null +++ b/packages/credential-provider-base/__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-base/index.ts b/packages/credential-provider-base/index.ts new file mode 100644 index 0000000000000..9f50fe78e5107 --- /dev/null +++ b/packages/credential-provider-base/index.ts @@ -0,0 +1,5 @@ +export * from './lib/chain'; +export * from './lib/CredentialError'; +export * from './lib/fromCredentials'; +export * from './lib/isCredentials'; +export * from './lib/memoize'; diff --git a/packages/credential-provider-base/lib/CredentialError.ts b/packages/credential-provider-base/lib/CredentialError.ts new file mode 100644 index 0000000000000..99e4a0e3ac2d5 --- /dev/null +++ b/packages/credential-provider-base/lib/CredentialError.ts @@ -0,0 +1,10 @@ +/** + * An error representing a failure of an individual credential provider. + * + * This error class has special meaning to + */ +export class CredentialError extends Error { + constructor(message: string, public readonly tryNextLink: boolean = true) { + super(message); + } +} diff --git a/packages/credential-provider-base/lib/chain.ts b/packages/credential-provider-base/lib/chain.ts new file mode 100755 index 0000000000000..b3f8a2e4121ae --- /dev/null +++ b/packages/credential-provider-base/lib/chain.ts @@ -0,0 +1,39 @@ +import {CredentialProvider} from "@aws/types"; +import {CredentialError} from "./CredentialError"; + +/** + * Compose a single credential provider function from multiple credential + * providers. The first provider in the argument list will always be invoked; + * subsequent providers in the list will be invoked in the order in which the + * were received if the preceding provider did not successfully resolve. + * + * If no providers were received or no provider resolves successfully, the + * returned promise will be rejected. + */ +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-base/lib/fromCredentials.ts b/packages/credential-provider-base/lib/fromCredentials.ts new file mode 100755 index 0000000000000..9798e517e3b1e --- /dev/null +++ b/packages/credential-provider-base/lib/fromCredentials.ts @@ -0,0 +1,10 @@ +import {CredentialProvider, Credentials} from "@aws/types"; + +/** + * Convert a static credentials object into a credential provider function. + */ +export function fromCredentials( + credentials: Credentials +): CredentialProvider { + return () => Promise.resolve(credentials); +} diff --git a/packages/credential-provider-base/lib/isCredentials.ts b/packages/credential-provider-base/lib/isCredentials.ts new file mode 100644 index 0000000000000..62dd2323e2e09 --- /dev/null +++ b/packages/credential-provider-base/lib/isCredentials.ts @@ -0,0 +1,14 @@ +import {Credentials} from '@aws/types'; + +/** + * Evaluate the provided argument and determine if it represents a static + * credentials object. + */ +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-base/lib/memoize.ts b/packages/credential-provider-base/lib/memoize.ts new file mode 100755 index 0000000000000..413b1a815f72a --- /dev/null +++ b/packages/credential-provider-base/lib/memoize.ts @@ -0,0 +1,36 @@ +import {CredentialProvider} from "@aws/types"; + +/** + * Decorates a credential provider with credential-specific memoization. If the + * decorated provider returns permanent credentials, it will only be invoked + * once; if the decorated provider returns temporary credentials, it will be + * invoked again when the validity of the returned credentials is less than 5 + * minutes. + */ +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-base/package.json b/packages/credential-provider-base/package.json new file mode 100755 index 0000000000000..6631716f856a6 --- /dev/null +++ b/packages/credential-provider-base/package.json @@ -0,0 +1,26 @@ +{ + "name": "@aws/credential-provider-base", + "version": "0.0.1", + "private": true, + "description": "AWS credential provider shared core", + "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" + }, + "devDependencies": { + "@types/jest": "^19.2.2", + "jest": "^19.0.2", + "typescript": "^2.3" + } +} diff --git a/packages/credential-provider-base/tsconfig.json b/packages/credential-provider-base/tsconfig.json new file mode 100755 index 0000000000000..73e7f9308ec94 --- /dev/null +++ b/packages/credential-provider-base/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "module": "commonjs", + "target": "es5", + "declaration": true, + "strict": true, + "sourceMap": true, + "lib": [ + "es5", + "es2015.promise" + ] + } +} From d5ee725369d2b8c59b9698c923f906cd12655e7b Mon Sep 17 00:00:00 2001 From: Jonathan Eskew Date: Wed, 14 Jun 2017 17:10:31 -0700 Subject: [PATCH 2/2] Finish description of CredentialError --- .../credential-provider-base/__tests__/fromCredentials.ts | 4 +++- packages/credential-provider-base/lib/CredentialError.ts | 8 +++++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/packages/credential-provider-base/__tests__/fromCredentials.ts b/packages/credential-provider-base/__tests__/fromCredentials.ts index 00f141242e01f..3fdcfac4da794 100644 --- a/packages/credential-provider-base/__tests__/fromCredentials.ts +++ b/packages/credential-provider-base/__tests__/fromCredentials.ts @@ -5,7 +5,9 @@ describe('fromCredentials', () => { it('should convert credentials into a credential provider', async () => { const credentials: Credentials = { accessKeyId: 'foo', - secretAccessKey: 'bar' + secretAccessKey: 'bar', + sessionToken: 'baz', + expiration: Math.floor(Date.now().valueOf() / 1000), }; const provider: CredentialProvider = fromCredentials(credentials); diff --git a/packages/credential-provider-base/lib/CredentialError.ts b/packages/credential-provider-base/lib/CredentialError.ts index 99e4a0e3ac2d5..23bb0073d3aa7 100644 --- a/packages/credential-provider-base/lib/CredentialError.ts +++ b/packages/credential-provider-base/lib/CredentialError.ts @@ -1,7 +1,13 @@ +import {chain} from './chain'; + /** * An error representing a failure of an individual credential provider. * - * This error class has special meaning to + * This error class has special meaning to the {@link chain} method. If a + * provider in the chain is rejected with an error, the chain will only proceed + * to the next provider if the value of the `tryNextLink` property on the error + * is truthy. This allows individual providers to halt the chain and also + * ensures the chain will stop if an entirely unexpected error is encountered. */ export class CredentialError extends Error { constructor(message: string, public readonly tryNextLink: boolean = true) {