Skip to content

Commit 93fad60

Browse files
authored
Merge pull request #26 from jeskew/feature/credential-provider-imds
Feature/credential provider imds
2 parents d39a521 + 3739348 commit 93fad60

17 files changed

+839
-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: 191 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,191 @@
1+
import {
2+
ENV_CMDS_AUTH_TOKEN,
3+
ENV_CMDS_FULL_URI,
4+
ENV_CMDS_RELATIVE_URI,
5+
fromContainerMetadata
6+
} from "../lib/fromContainerMetadata";
7+
import { httpGet } from "../lib/remoteProvider/httpGet";
8+
import {
9+
fromImdsCredentials,
10+
ImdsCredentials
11+
} from "../lib/remoteProvider/ImdsCredentials";
12+
import MockInstance = jest.MockInstance;
13+
import { RequestOptions } from "http";
14+
15+
interface HttpGet {
16+
(options: RequestOptions): Promise<Buffer>;
17+
}
18+
19+
const mockHttpGet = <MockInstance<HttpGet>>(<any>httpGet);
20+
jest.mock("../lib/remoteProvider/httpGet", () => ({ httpGet: jest.fn() }));
21+
22+
const relativeUri = process.env[ENV_CMDS_RELATIVE_URI];
23+
const fullUri = process.env[ENV_CMDS_FULL_URI];
24+
const authToken = process.env[ENV_CMDS_AUTH_TOKEN];
25+
26+
beforeEach(() => {
27+
mockHttpGet.mockReset();
28+
delete process.env[ENV_CMDS_RELATIVE_URI];
29+
delete process.env[ENV_CMDS_FULL_URI];
30+
delete process.env[ENV_CMDS_AUTH_TOKEN];
31+
});
32+
33+
afterAll(() => {
34+
process.env[ENV_CMDS_RELATIVE_URI] = relativeUri;
35+
process.env[ENV_CMDS_FULL_URI] = fullUri;
36+
process.env[ENV_CMDS_AUTH_TOKEN] = authToken;
37+
});
38+
39+
describe("fromContainerMetadata", () => {
40+
const creds: ImdsCredentials = Object.freeze({
41+
AccessKeyId: "foo",
42+
SecretAccessKey: "bar",
43+
Token: "baz",
44+
Expiration: new Date().toISOString()
45+
});
46+
47+
it("should reject the promise with a terminal error if the container credentials environment variable is not set", async () => {
48+
await fromContainerMetadata()().then(
49+
() => {
50+
throw new Error("The promise should have been rejected");
51+
},
52+
err => {
53+
expect((err as any).tryNextLink).toBeFalsy();
54+
}
55+
);
56+
});
57+
58+
it(`should inject an authorization header containing the contents of the ${ENV_CMDS_AUTH_TOKEN} environment variable if defined`, async () => {
59+
const token = "Basic abcd";
60+
process.env[ENV_CMDS_FULL_URI] = "http://localhost:8080/path";
61+
process.env[ENV_CMDS_AUTH_TOKEN] = token;
62+
mockHttpGet.mockReturnValue(Promise.resolve(JSON.stringify(creds)));
63+
64+
await fromContainerMetadata()();
65+
66+
expect(mockHttpGet.mock.calls.length).toBe(1);
67+
const [options = {}] = mockHttpGet.mock.calls[0];
68+
expect(options.headers).toMatchObject({
69+
Authorization: token
70+
});
71+
});
72+
73+
describe(ENV_CMDS_RELATIVE_URI, () => {
74+
beforeEach(() => {
75+
process.env[ENV_CMDS_RELATIVE_URI] = "/relative/uri";
76+
});
77+
78+
it("should resolve credentials by fetching them from the container metadata service", async () => {
79+
mockHttpGet.mockReturnValue(Promise.resolve(JSON.stringify(creds)));
80+
81+
expect(await fromContainerMetadata()()).toEqual(
82+
fromImdsCredentials(creds)
83+
);
84+
});
85+
86+
it("should retry the fetching operation up to maxRetries times", async () => {
87+
const maxRetries = 5;
88+
for (let i = 0; i < maxRetries - 1; i++) {
89+
mockHttpGet.mockReturnValueOnce(Promise.reject("No!"));
90+
}
91+
mockHttpGet.mockReturnValueOnce(Promise.resolve(JSON.stringify(creds)));
92+
93+
expect(await fromContainerMetadata({ maxRetries })()).toEqual(
94+
fromImdsCredentials(creds)
95+
);
96+
expect(mockHttpGet.mock.calls.length).toEqual(maxRetries);
97+
});
98+
99+
it("should retry responses that receive invalid response values", async () => {
100+
for (let key of Object.keys(creds)) {
101+
const invalidCreds: any = { ...creds };
102+
delete invalidCreds[key];
103+
mockHttpGet.mockReturnValueOnce(
104+
Promise.resolve(JSON.stringify(invalidCreds))
105+
);
106+
}
107+
mockHttpGet.mockReturnValueOnce(Promise.resolve(JSON.stringify(creds)));
108+
109+
await fromContainerMetadata({ maxRetries: 100 })();
110+
expect(mockHttpGet.mock.calls.length).toEqual(
111+
Object.keys(creds).length + 1
112+
);
113+
});
114+
115+
it("should pass relevant configuration to httpGet", async () => {
116+
const timeout = Math.ceil(Math.random() * 1000);
117+
mockHttpGet.mockReturnValue(Promise.resolve(JSON.stringify(creds)));
118+
await fromContainerMetadata({ timeout })();
119+
expect(mockHttpGet.mock.calls.length).toEqual(1);
120+
expect(mockHttpGet.mock.calls[0][0]).toEqual({
121+
hostname: "169.254.170.2",
122+
path: process.env[ENV_CMDS_RELATIVE_URI],
123+
timeout
124+
});
125+
});
126+
});
127+
128+
describe(ENV_CMDS_FULL_URI, () => {
129+
it("should pass relevant configuration to httpGet", async () => {
130+
process.env[ENV_CMDS_FULL_URI] = "http://localhost:8080/path";
131+
132+
const timeout = Math.ceil(Math.random() * 1000);
133+
mockHttpGet.mockReturnValue(Promise.resolve(JSON.stringify(creds)));
134+
await fromContainerMetadata({ timeout })();
135+
expect(mockHttpGet.mock.calls.length).toEqual(1);
136+
const {
137+
protocol,
138+
hostname,
139+
path,
140+
port,
141+
timeout: actualTimeout
142+
} = mockHttpGet.mock.calls[0][0];
143+
expect(protocol).toBe("http:");
144+
expect(hostname).toBe("localhost");
145+
expect(path).toBe("/path");
146+
expect(port).toBe(8080);
147+
expect(actualTimeout).toBe(timeout);
148+
});
149+
150+
it(`should prefer ${ENV_CMDS_RELATIVE_URI} to ${ENV_CMDS_FULL_URI}`, async () => {
151+
process.env[ENV_CMDS_RELATIVE_URI] = "foo";
152+
process.env[ENV_CMDS_FULL_URI] = "http://localhost:8080/path";
153+
154+
const timeout = Math.ceil(Math.random() * 1000);
155+
mockHttpGet.mockReturnValue(Promise.resolve(JSON.stringify(creds)));
156+
await fromContainerMetadata({ timeout })();
157+
expect(mockHttpGet.mock.calls.length).toEqual(1);
158+
expect(mockHttpGet.mock.calls[0][0]).toEqual({
159+
hostname: "169.254.170.2",
160+
path: "foo",
161+
timeout
162+
});
163+
});
164+
165+
it("should reject the promise with a terminal error if a unexpected protocol is specified", async () => {
166+
process.env[ENV_CMDS_FULL_URI] = "wss://localhost:8080/path";
167+
168+
await fromContainerMetadata()().then(
169+
() => {
170+
throw new Error("The promise should have been rejected");
171+
},
172+
err => {
173+
expect((err as any).tryNextLink).toBeFalsy();
174+
}
175+
);
176+
});
177+
178+
it("should reject the promise with a terminal error if a unexpected hostname is specified", async () => {
179+
process.env[ENV_CMDS_FULL_URI] = "https://bucket.s3.amazonaws.com/key";
180+
181+
await fromContainerMetadata()().then(
182+
() => {
183+
throw new Error("The promise should have been rejected");
184+
},
185+
err => {
186+
expect((err as any).tryNextLink).toBeFalsy();
187+
}
188+
);
189+
});
190+
});
191+
});
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
import { fromInstanceMetadata } from "../lib/fromInstanceMetadata";
2+
import { httpGet } from "../lib/remoteProvider/httpGet";
3+
import {
4+
fromImdsCredentials,
5+
ImdsCredentials
6+
} from "../lib/remoteProvider/ImdsCredentials";
7+
import MockInstance = jest.MockInstance;
8+
import { RequestOptions } from "http";
9+
10+
interface HttpGet {
11+
(options: RequestOptions): Promise<Buffer>;
12+
}
13+
14+
const mockHttpGet = <MockInstance<HttpGet>>(<any>httpGet);
15+
jest.mock("../lib/remoteProvider/httpGet", () => ({ httpGet: jest.fn() }));
16+
17+
beforeEach(() => {
18+
mockHttpGet.mockReset();
19+
});
20+
21+
describe("fromInstanceMetadata", () => {
22+
const creds: ImdsCredentials = Object.freeze({
23+
AccessKeyId: "foo",
24+
SecretAccessKey: "bar",
25+
Token: "baz",
26+
Expiration: new Date().toISOString()
27+
});
28+
29+
it("should resolve credentials by fetching them from the container metadata service", async () => {
30+
mockHttpGet.mockReturnValue(Promise.resolve(JSON.stringify(creds)));
31+
expect(await fromInstanceMetadata({ profile: "foo" })()).toEqual(
32+
fromImdsCredentials(creds)
33+
);
34+
});
35+
36+
it("should retry the fetching operation up to maxRetries times", async () => {
37+
const maxRetries = 5;
38+
for (let i = 0; i < maxRetries - 1; i++) {
39+
mockHttpGet.mockReturnValueOnce(Promise.reject("No!"));
40+
}
41+
mockHttpGet.mockReturnValueOnce(Promise.resolve(JSON.stringify(creds)));
42+
43+
expect(
44+
await fromInstanceMetadata({ maxRetries, profile: "foo" })()
45+
).toEqual(fromImdsCredentials(creds));
46+
expect(mockHttpGet.mock.calls.length).toEqual(maxRetries);
47+
});
48+
49+
it("should retry responses that receive invalid response values", async () => {
50+
for (let key of Object.keys(creds)) {
51+
const invalidCreds: any = { ...creds };
52+
delete invalidCreds[key];
53+
mockHttpGet.mockReturnValueOnce(
54+
Promise.resolve(JSON.stringify(invalidCreds))
55+
);
56+
}
57+
mockHttpGet.mockReturnValueOnce(Promise.resolve(JSON.stringify(creds)));
58+
59+
await fromInstanceMetadata({ maxRetries: 100, profile: "foo" })();
60+
expect(mockHttpGet.mock.calls.length).toEqual(
61+
Object.keys(creds).length + 1
62+
);
63+
});
64+
65+
it("should pass relevant configuration to httpGet", async () => {
66+
const timeout = Math.ceil(Math.random() * 1000);
67+
const profile = "foo-profile";
68+
mockHttpGet.mockReturnValue(Promise.resolve(JSON.stringify(creds)));
69+
await fromInstanceMetadata({ timeout, profile })();
70+
expect(mockHttpGet.mock.calls.length).toEqual(1);
71+
expect(mockHttpGet.mock.calls[0][0]).toEqual({
72+
host: "169.254.169.254",
73+
path: `/latest/meta-data/iam/security-credentials/${profile}`,
74+
timeout
75+
});
76+
});
77+
78+
it("should fetch the profile name if not supplied", async () => {
79+
const defaultTimeout = 1000;
80+
const profile = "foo-profile";
81+
mockHttpGet.mockReturnValueOnce(Promise.resolve(profile));
82+
mockHttpGet.mockReturnValueOnce(Promise.resolve(JSON.stringify(creds)));
83+
84+
await fromInstanceMetadata()();
85+
expect(mockHttpGet.mock.calls.length).toEqual(2);
86+
expect(mockHttpGet.mock.calls[0][0]).toEqual({
87+
host: "169.254.169.254",
88+
path: "/latest/meta-data/iam/security-credentials/",
89+
timeout: defaultTimeout
90+
});
91+
expect(mockHttpGet.mock.calls[1][0]).toEqual({
92+
host: "169.254.169.254",
93+
path: `/latest/meta-data/iam/security-credentials/${profile}`,
94+
timeout: defaultTimeout
95+
});
96+
});
97+
98+
it("should retry the profile name fetch as necessary", async () => {
99+
const defaultTimeout = 1000;
100+
const profile = "foo-profile";
101+
mockHttpGet.mockReturnValueOnce(Promise.reject("Too busy"));
102+
mockHttpGet.mockReturnValueOnce(Promise.resolve(profile));
103+
mockHttpGet.mockReturnValueOnce(Promise.resolve(JSON.stringify(creds)));
104+
105+
await fromInstanceMetadata({ maxRetries: 1 })();
106+
expect(mockHttpGet.mock.calls.length).toEqual(3);
107+
expect(mockHttpGet.mock.calls[2][0]).toEqual({
108+
host: "169.254.169.254",
109+
path: `/latest/meta-data/iam/security-credentials/${profile}`,
110+
timeout: defaultTimeout
111+
});
112+
for (let index of [0, 1]) {
113+
expect(mockHttpGet.mock.calls[index][0]).toEqual({
114+
host: "169.254.169.254",
115+
path: "/latest/meta-data/iam/security-credentials/",
116+
timeout: defaultTimeout
117+
});
118+
}
119+
});
120+
});
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import {
2+
fromImdsCredentials,
3+
ImdsCredentials,
4+
isImdsCredentials
5+
} from "../../lib/remoteProvider/ImdsCredentials";
6+
import { Credentials } from "@aws/types";
7+
8+
const creds: ImdsCredentials = Object.freeze({
9+
AccessKeyId: "foo",
10+
SecretAccessKey: "bar",
11+
Token: "baz",
12+
Expiration: new Date().toISOString()
13+
});
14+
15+
describe("isImdsCredentials", () => {
16+
it("should accept valid ImdsCredentials objects", () => {
17+
expect(isImdsCredentials(creds)).toBe(true);
18+
});
19+
20+
it("should reject credentials without an AccessKeyId", () => {
21+
expect(isImdsCredentials({ ...creds, AccessKeyId: void 0 })).toBe(false);
22+
});
23+
24+
it("should reject credentials without a SecretAccessKey", () => {
25+
expect(isImdsCredentials({ ...creds, SecretAccessKey: void 0 })).toBe(
26+
false
27+
);
28+
});
29+
30+
it("should reject credentials without a Token", () => {
31+
expect(isImdsCredentials({ ...creds, Token: void 0 })).toBe(false);
32+
});
33+
34+
it("should reject credentials without an Expiration", () => {
35+
expect(isImdsCredentials({ ...creds, Expiration: void 0 })).toBe(false);
36+
});
37+
38+
it("should reject scalar values", () => {
39+
for (let scalar of ["string", 1, true, null, void 0]) {
40+
expect(isImdsCredentials(scalar)).toBe(false);
41+
}
42+
});
43+
});
44+
45+
describe("fromImdsCredentials", () => {
46+
it("should convert IMDS credentials to a credentials object", () => {
47+
const converted: Credentials = fromImdsCredentials(creds);
48+
expect(converted.accessKeyId).toEqual(creds.AccessKeyId);
49+
expect(converted.secretAccessKey).toEqual(creds.SecretAccessKey);
50+
expect(converted.sessionToken).toEqual(creds.Token);
51+
expect(converted.expiration).toEqual(
52+
Math.floor(new Date(creds.Expiration).valueOf() / 1000)
53+
);
54+
});
55+
});
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import {
2+
DEFAULT_MAX_RETRIES,
3+
DEFAULT_TIMEOUT,
4+
providerConfigFromInit
5+
} from "../../lib/remoteProvider/RemoteProviderInit";
6+
7+
describe("providerConfigFromInit", () => {
8+
it("should populate default values for retries and timeouts", () => {
9+
expect(providerConfigFromInit({})).toEqual({
10+
timeout: DEFAULT_TIMEOUT,
11+
maxRetries: DEFAULT_MAX_RETRIES
12+
});
13+
});
14+
15+
it("should pass through timeout and retries overrides", () => {
16+
const timeout = 123456789;
17+
const maxRetries = 987654321;
18+
19+
expect(providerConfigFromInit({ timeout, maxRetries })).toEqual({
20+
timeout,
21+
maxRetries
22+
});
23+
});
24+
});

0 commit comments

Comments
 (0)