Skip to content

Add a base credential provider package #23

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 2 commits into from
Jun 15, 2017
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions packages/credential-provider-base/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
/node_modules/
*.js
*.js.map
*.d.ts
11 changes: 11 additions & 0 deletions packages/credential-provider-base/__tests__/CredentialError.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
70 changes: 70 additions & 0 deletions packages/credential-provider-base/__tests__/chain.ts
Original file line number Diff line number Diff line change
@@ -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 */ }
);
});
});
18 changes: 18 additions & 0 deletions packages/credential-provider-base/__tests__/fromCredentials.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import {CredentialProvider, Credentials} from "@aws/types";
import {fromCredentials} from "../lib/fromCredentials";

describe('fromCredentials', () => {
it('should convert credentials into a credential provider', async () => {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor: Should a test also be included for sessionToken/expiration?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

sure

const credentials: Credentials = {
accessKeyId: 'foo',
secretAccessKey: 'bar',
sessionToken: 'baz',
expiration: Math.floor(Date.now().valueOf() / 1000),
};
const provider: CredentialProvider = fromCredentials(credentials);

expect(typeof provider).toBe('function');
expect(provider()).toBeInstanceOf(Promise);
expect(await provider()).toEqual(credentials);
});
});
57 changes: 57 additions & 0 deletions packages/credential-provider-base/__tests__/isCredentials.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
38 changes: 38 additions & 0 deletions packages/credential-provider-base/__tests__/memoize.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
5 changes: 5 additions & 0 deletions packages/credential-provider-base/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export * from './lib/chain';
export * from './lib/CredentialError';
export * from './lib/fromCredentials';
export * from './lib/isCredentials';
export * from './lib/memoize';
16 changes: 16 additions & 0 deletions packages/credential-provider-base/lib/CredentialError.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import {chain} from './chain';

/**
* An error representing a failure of an individual credential provider.
*
* 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) {
super(message);
}
}
39 changes: 39 additions & 0 deletions packages/credential-provider-base/lib/chain.ts
Original file line number Diff line number Diff line change
@@ -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>
): 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;
}
}
10 changes: 10 additions & 0 deletions packages/credential-provider-base/lib/fromCredentials.ts
Original file line number Diff line number Diff line change
@@ -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);
}
14 changes: 14 additions & 0 deletions packages/credential-provider-base/lib/isCredentials.ts
Original file line number Diff line number Diff line change
@@ -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;
}
36 changes: 36 additions & 0 deletions packages/credential-provider-base/lib/memoize.ts
Original file line number Diff line number Diff line change
@@ -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);
}
26 changes: 26 additions & 0 deletions packages/credential-provider-base/package.json
Original file line number Diff line number Diff line change
@@ -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": "[email protected]",
"license": "UNLICENSED",
"dependencies": {
"@aws/types": "^0.0.1"
},
"devDependencies": {
"@types/jest": "^19.2.2",
"jest": "^19.0.2",
"typescript": "^2.3"
}
}
13 changes: 13 additions & 0 deletions packages/credential-provider-base/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{
"compilerOptions": {
"module": "commonjs",
"target": "es5",
"declaration": true,
"strict": true,
"sourceMap": true,
"lib": [
"es5",
"es2015.promise"
]
}
}