Skip to content

Commit cebedea

Browse files
authored
Merge pull request #23 from jeskew/feature/credential-provider-base
Add a base credential provider package
2 parents bb8033a + d5ee725 commit cebedea

File tree

14 files changed

+357
-0
lines changed

14 files changed

+357
-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: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
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+
sessionToken: 'baz',
10+
expiration: Math.floor(Date.now().valueOf() / 1000),
11+
};
12+
const provider: CredentialProvider = fromCredentials(credentials);
13+
14+
expect(typeof provider).toBe('function');
15+
expect(provider()).toBeInstanceOf(Promise);
16+
expect(await provider()).toEqual(credentials);
17+
});
18+
});
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: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import {chain} from './chain';
2+
3+
/**
4+
* An error representing a failure of an individual credential provider.
5+
*
6+
* This error class has special meaning to the {@link chain} method. If a
7+
* provider in the chain is rejected with an error, the chain will only proceed
8+
* to the next provider if the value of the `tryNextLink` property on the error
9+
* is truthy. This allows individual providers to halt the chain and also
10+
* ensures the chain will stop if an entirely unexpected error is encountered.
11+
*/
12+
export class CredentialError extends Error {
13+
constructor(message: string, public readonly tryNextLink: boolean = true) {
14+
super(message);
15+
}
16+
}
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+
}

0 commit comments

Comments
 (0)