Skip to content

Commit 395a6eb

Browse files
committed
Add a base credential provider package
1 parent bb8033a commit 395a6eb

File tree

14 files changed

+349
-0
lines changed

14 files changed

+349
-0
lines changed
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
/node_modules/
2+
*.js
3+
*.js.map
4+
*.d.ts
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import {CredentialError} from "../lib/CredentialError";
2+
3+
describe('CredentialError', () => {
4+
it('should direct the chain to proceed to the next link by default', () => {
5+
expect(new CredentialError('PANIC').tryNextLink).toBe(true);
6+
});
7+
8+
it('should allow errors to halt the chain', () => {
9+
expect(new CredentialError('PANIC', false).tryNextLink).toBe(false);
10+
});
11+
});
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import {chain} from "../lib/chain";
2+
import {fromCredentials} from "../lib/fromCredentials";
3+
import {isCredentials} from "../lib/isCredentials";
4+
import {CredentialError} from "../lib/CredentialError";
5+
6+
describe('chain', () => {
7+
it('should distill many credential providers into one', async () => {
8+
const provider = chain(
9+
fromCredentials({accessKeyId: 'foo', secretAccessKey: 'bar'}),
10+
fromCredentials({accessKeyId: 'baz', secretAccessKey: 'quux'}),
11+
);
12+
13+
expect(isCredentials(await provider())).toBe(true);
14+
});
15+
16+
it('should return the resolved value of the first successful promise', async () => {
17+
const creds = {accessKeyId: 'foo', secretAccessKey: 'bar'};
18+
const provider = chain(
19+
() => Promise.reject(new CredentialError('Move along')),
20+
() => Promise.reject(new CredentialError('Nothing to see here')),
21+
fromCredentials(creds)
22+
);
23+
24+
expect(await provider()).toEqual(creds);
25+
});
26+
27+
it('should not invoke subsequent providers one resolves', async () => {
28+
const creds = {accessKeyId: 'foo', secretAccessKey: 'bar'};
29+
const providers = [
30+
jest.fn(() => Promise.reject(new CredentialError('Move along'))),
31+
jest.fn(() => Promise.resolve(creds)),
32+
jest.fn(() => fail('This provider should not be invoked'))
33+
];
34+
35+
expect(await chain(...providers)()).toEqual(creds);
36+
expect(providers[0].mock.calls.length).toBe(1);
37+
expect(providers[1].mock.calls.length).toBe(1);
38+
expect(providers[2].mock.calls.length).toBe(0);
39+
});
40+
41+
it(
42+
'should not invoke subsequent providers one is rejected with a terminal error',
43+
async () => {
44+
const providers = [
45+
jest.fn(() => Promise.reject(new CredentialError('Move along'))),
46+
jest.fn(() => Promise.reject(
47+
new CredentialError('Stop here', false)
48+
)),
49+
jest.fn(() => fail('This provider should not be invoked'))
50+
];
51+
52+
await chain(...providers)().then(
53+
() => { throw new Error('The promise should have been rejected'); },
54+
err => {
55+
expect(err.message).toBe('Stop here');
56+
expect(providers[0].mock.calls.length).toBe(1);
57+
expect(providers[1].mock.calls.length).toBe(1);
58+
expect(providers[2].mock.calls.length).toBe(0);
59+
}
60+
);
61+
}
62+
);
63+
64+
it('should reject chains with no links', async () => {
65+
await chain()().then(
66+
() => { throw new Error('The promise should have been rejected'); },
67+
() => { /* Promise rejected as expected */ }
68+
);
69+
});
70+
});
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import {CredentialProvider, Credentials} from "@aws/types";
2+
import {fromCredentials} from "../lib/fromCredentials";
3+
4+
describe('fromCredentials', () => {
5+
it('should convert credentials into a credential provider', async () => {
6+
const credentials: Credentials = {
7+
accessKeyId: 'foo',
8+
secretAccessKey: 'bar'
9+
};
10+
const provider: CredentialProvider = fromCredentials(credentials);
11+
12+
expect(typeof provider).toBe('function');
13+
expect(provider()).toBeInstanceOf(Promise);
14+
expect(await provider()).toEqual(credentials);
15+
});
16+
});
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import {isCredentials} from "../lib/isCredentials";
2+
3+
describe('isCredentials', () => {
4+
const minimalCredentials = {accessKeyId: 'foo', secretAccessKey: 'bar'};
5+
6+
it('should reject scalar values', () => {
7+
for (let scalar of ['foo', 12, 1.2, true, null, undefined]) {
8+
expect(isCredentials(scalar)).toBe(false);
9+
}
10+
});
11+
12+
it('should accept an object with an accessKeyId and secretAccessKey', () => {
13+
expect(isCredentials(minimalCredentials)).toBe(true);
14+
});
15+
16+
it('should reject objects where accessKeyId is not a string', () => {
17+
expect(isCredentials({
18+
...minimalCredentials,
19+
accessKeyId: 123,
20+
})).toBe(false);
21+
});
22+
23+
it('should reject objects where secretAccessKey is not a string', () => {
24+
expect(isCredentials({
25+
...minimalCredentials,
26+
secretAccessKey: 123,
27+
})).toBe(false);
28+
});
29+
30+
it('should accept credentials with a sessionToken', () => {
31+
expect(isCredentials({
32+
...minimalCredentials,
33+
sessionToken: 'baz',
34+
})).toBe(true);
35+
});
36+
37+
it('should reject credentials where sessionToken is not a string', () => {
38+
expect(isCredentials({
39+
...minimalCredentials,
40+
sessionToken: 123,
41+
})).toBe(false);
42+
});
43+
44+
it('should accept credentials with an expiration', () => {
45+
expect(isCredentials({
46+
...minimalCredentials,
47+
expiration: 0,
48+
})).toBe(true);
49+
});
50+
51+
it('should reject credentials where expiration is not a number', () => {
52+
expect(isCredentials({
53+
...minimalCredentials,
54+
expiration: 'quux',
55+
})).toBe(false);
56+
});
57+
});
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import {memoize} from "../lib/memoize";
2+
3+
describe('memoize', () => {
4+
it('should cache the resolved provider for permanent credentials', async () => {
5+
const creds = {accessKeyId: 'foo', secretAccessKey: 'bar'};
6+
const provider = jest.fn(() => Promise.resolve(creds));
7+
const memoized = memoize(provider);
8+
9+
expect(await memoized()).toEqual(creds);
10+
expect(provider.mock.calls.length).toBe(1);
11+
expect(await memoized()).toEqual(creds);
12+
expect(provider.mock.calls.length).toBe(1);
13+
});
14+
15+
it('should invoke provider again when credentials expire', async () => {
16+
const clockMock = Date.now = jest.fn();
17+
clockMock.mockReturnValue(0);
18+
const provider = jest.fn(() => Promise.resolve({
19+
accessKeyId: 'foo',
20+
secretAccessKey: 'bar',
21+
expiration: Date.now() + 600, // expires in ten minutes
22+
}));
23+
const memoized = memoize(provider);
24+
25+
expect((await memoized()).accessKeyId).toEqual('foo');
26+
expect(provider.mock.calls.length).toBe(1);
27+
expect((await memoized()).secretAccessKey).toEqual('bar');
28+
expect(provider.mock.calls.length).toBe(1);
29+
30+
clockMock.mockReset();
31+
clockMock.mockReturnValue(601000); // One second past previous expiration
32+
33+
expect((await memoized()).accessKeyId).toEqual('foo');
34+
expect(provider.mock.calls.length).toBe(2);
35+
expect((await memoized()).secretAccessKey).toEqual('bar');
36+
expect(provider.mock.calls.length).toBe(2);
37+
});
38+
});
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
export * from './lib/chain';
2+
export * from './lib/CredentialError';
3+
export * from './lib/fromCredentials';
4+
export * from './lib/isCredentials';
5+
export * from './lib/memoize';
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
/**
2+
* An error representing a failure of an individual credential provider.
3+
*
4+
* This error class has special meaning to
5+
*/
6+
export class CredentialError extends Error {
7+
constructor(message: string, public readonly tryNextLink: boolean = true) {
8+
super(message);
9+
}
10+
}
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import {CredentialProvider} from "@aws/types";
2+
import {CredentialError} from "./CredentialError";
3+
4+
/**
5+
* Compose a single credential provider function from multiple credential
6+
* providers. The first provider in the argument list will always be invoked;
7+
* subsequent providers in the list will be invoked in the order in which the
8+
* were received if the preceding provider did not successfully resolve.
9+
*
10+
* If no providers were received or no provider resolves successfully, the
11+
* returned promise will be rejected.
12+
*/
13+
export function chain(
14+
...providers: Array<CredentialProvider>
15+
): CredentialProvider {
16+
return () => {
17+
providers = providers.slice(0);
18+
let provider = providers.shift();
19+
if (provider === undefined) {
20+
return Promise.reject(new CredentialError(
21+
'No credential providers in chain'
22+
));
23+
}
24+
let promise = provider();
25+
while (provider = providers.shift()) {
26+
promise = promise.catch((provider => {
27+
return (err: CredentialError) => {
28+
if (err.tryNextLink) {
29+
return provider();
30+
}
31+
32+
throw err;
33+
}
34+
})(provider));
35+
}
36+
37+
return promise;
38+
}
39+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import {CredentialProvider, Credentials} from "@aws/types";
2+
3+
/**
4+
* Convert a static credentials object into a credential provider function.
5+
*/
6+
export function fromCredentials(
7+
credentials: Credentials
8+
): CredentialProvider {
9+
return () => Promise.resolve(credentials);
10+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import {Credentials} from '@aws/types';
2+
3+
/**
4+
* Evaluate the provided argument and determine if it represents a static
5+
* credentials object.
6+
*/
7+
export function isCredentials(arg: any): arg is Credentials {
8+
return typeof arg === 'object'
9+
&& arg !== null
10+
&& typeof arg.accessKeyId === 'string'
11+
&& typeof arg.secretAccessKey === 'string'
12+
&& ['string', 'undefined'].indexOf(typeof arg.sessionToken) > -1
13+
&& ['number', 'undefined'].indexOf(typeof arg.expiration) > -1;
14+
}
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import {CredentialProvider} from "@aws/types";
2+
3+
/**
4+
* Decorates a credential provider with credential-specific memoization. If the
5+
* decorated provider returns permanent credentials, it will only be invoked
6+
* once; if the decorated provider returns temporary credentials, it will be
7+
* invoked again when the validity of the returned credentials is less than 5
8+
* minutes.
9+
*/
10+
export function memoize(provider: CredentialProvider): CredentialProvider {
11+
let result = provider();
12+
let isConstant: boolean = false;
13+
14+
return () => {
15+
if (isConstant) {
16+
return result;
17+
}
18+
19+
return result.then(credentials => {
20+
if (!credentials.expiration) {
21+
isConstant = true;
22+
return credentials;
23+
}
24+
25+
if (credentials.expiration - 300 > getEpochTs()) {
26+
return credentials;
27+
}
28+
29+
return result = provider();
30+
});
31+
}
32+
}
33+
34+
function getEpochTs() {
35+
return Math.floor(Date.now() / 1000);
36+
}
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
{
2+
"name": "@aws/credential-provider-base",
3+
"version": "0.0.1",
4+
"private": true,
5+
"description": "AWS credential provider shared core",
6+
"main": "index.js",
7+
"scripts": {
8+
"prepublishOnly": "tsc",
9+
"pretest": "tsc",
10+
"test": "jest"
11+
},
12+
"keywords": [
13+
"aws",
14+
"credentials"
15+
],
16+
"author": "[email protected]",
17+
"license": "UNLICENSED",
18+
"dependencies": {
19+
"@aws/types": "^0.0.1"
20+
},
21+
"devDependencies": {
22+
"@types/jest": "^19.2.2",
23+
"jest": "^19.0.2",
24+
"typescript": "^2.3"
25+
}
26+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
{
2+
"compilerOptions": {
3+
"module": "commonjs",
4+
"target": "es5",
5+
"declaration": true,
6+
"strict": true,
7+
"sourceMap": true,
8+
"lib": [
9+
"es5",
10+
"es2015.promise"
11+
]
12+
}
13+
}

0 commit comments

Comments
 (0)