Skip to content

Commit 6a9a954

Browse files
authored
Merge pull request #23 from jeskew/feature/credential-provider-base
Add a base credential provider package
2 parents bb79147 + b259db9 commit 6a9a954

File tree

14 files changed

+374
-0
lines changed

14 files changed

+374
-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: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
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("should not invoke subsequent providers one is rejected with a terminal error", async () => {
42+
const providers = [
43+
jest.fn(() => Promise.reject(new CredentialError("Move along"))),
44+
jest.fn(() => Promise.reject(new CredentialError("Stop here", false))),
45+
jest.fn(() => fail("This provider should not be invoked"))
46+
];
47+
48+
await chain(...providers)().then(
49+
() => {
50+
throw new Error("The promise should have been rejected");
51+
},
52+
err => {
53+
expect(err.message).toBe("Stop here");
54+
expect(providers[0].mock.calls.length).toBe(1);
55+
expect(providers[1].mock.calls.length).toBe(1);
56+
expect(providers[2].mock.calls.length).toBe(0);
57+
}
58+
);
59+
});
60+
61+
it("should reject chains with no links", async () => {
62+
await chain()().then(
63+
() => {
64+
throw new Error("The promise should have been rejected");
65+
},
66+
() => {
67+
/* Promise rejected as expected */
68+
}
69+
);
70+
});
71+
});
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: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
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(
18+
isCredentials({
19+
...minimalCredentials,
20+
accessKeyId: 123
21+
})
22+
).toBe(false);
23+
});
24+
25+
it("should reject objects where secretAccessKey is not a string", () => {
26+
expect(
27+
isCredentials({
28+
...minimalCredentials,
29+
secretAccessKey: 123
30+
})
31+
).toBe(false);
32+
});
33+
34+
it("should accept credentials with a sessionToken", () => {
35+
expect(
36+
isCredentials({
37+
...minimalCredentials,
38+
sessionToken: "baz"
39+
})
40+
).toBe(true);
41+
});
42+
43+
it("should reject credentials where sessionToken is not a string", () => {
44+
expect(
45+
isCredentials({
46+
...minimalCredentials,
47+
sessionToken: 123
48+
})
49+
).toBe(false);
50+
});
51+
52+
it("should accept credentials with an expiration", () => {
53+
expect(
54+
isCredentials({
55+
...minimalCredentials,
56+
expiration: 0
57+
})
58+
).toBe(true);
59+
});
60+
61+
it("should reject credentials where expiration is not a number", () => {
62+
expect(
63+
isCredentials({
64+
...minimalCredentials,
65+
expiration: "quux"
66+
})
67+
).toBe(false);
68+
});
69+
});
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
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(() =>
19+
Promise.resolve({
20+
accessKeyId: "foo",
21+
secretAccessKey: "bar",
22+
expiration: Date.now() + 600 // expires in ten minutes
23+
})
24+
);
25+
const memoized = memoize(provider);
26+
27+
expect((await memoized()).accessKeyId).toEqual("foo");
28+
expect(provider.mock.calls.length).toBe(1);
29+
expect((await memoized()).secretAccessKey).toEqual("bar");
30+
expect(provider.mock.calls.length).toBe(1);
31+
32+
clockMock.mockReset();
33+
clockMock.mockReturnValue(601000); // One second past previous expiration
34+
35+
expect((await memoized()).accessKeyId).toEqual("foo");
36+
expect(provider.mock.calls.length).toBe(2);
37+
expect((await memoized()).secretAccessKey).toEqual("bar");
38+
expect(provider.mock.calls.length).toBe(2);
39+
});
40+
});
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: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
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(
21+
new CredentialError("No credential providers in chain")
22+
);
23+
}
24+
let promise = provider();
25+
while ((provider = providers.shift())) {
26+
promise = promise.catch(
27+
(provider => {
28+
return (err: CredentialError) => {
29+
if (err.tryNextLink) {
30+
return provider();
31+
}
32+
33+
throw err;
34+
};
35+
})(provider)
36+
);
37+
}
38+
39+
return promise;
40+
};
41+
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
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(credentials: Credentials): CredentialProvider {
7+
return () => Promise.resolve(credentials);
8+
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
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 (
9+
typeof arg === "object" &&
10+
arg !== null &&
11+
typeof arg.accessKeyId === "string" &&
12+
typeof arg.secretAccessKey === "string" &&
13+
["string", "undefined"].indexOf(typeof arg.sessionToken) > -1 &&
14+
["number", "undefined"].indexOf(typeof arg.expiration) > -1
15+
);
16+
}
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)