Skip to content

Commit fc7af7d

Browse files
committed
Add support for remote credential providers
1 parent ea21b97 commit fc7af7d

16 files changed

+607
-4
lines changed

packages/credential-provider/__mocks__/fs.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
interface FsModule {
2-
__addMatcher: (toMatch: RegExp, toReturn: string) => void;
3-
__clearMatchers: () => void;
2+
__addMatcher(toMatch: RegExp, toReturn: string): void;
3+
__clearMatchers(): void;
44
readFile: (path: string, encoding: string, cb: Function) => void
55
}
66

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
import {
2+
ENV_CMDS_RELATIVE_URI,
3+
fromContainerMetadata
4+
} from "../lib/fromContainerMetadata";
5+
import {httpGet} from "../lib/remoteProvider/httpGet";
6+
import {
7+
fromImdsCredentials,
8+
ImdsCredentials
9+
} from "../lib/remoteProvider/ImdsCredentials";
10+
import MockInstance = jest.MockInstance;
11+
import {RequestOptions} from "http";
12+
13+
interface HttpGet {
14+
(options: RequestOptions): Promise<Buffer>;
15+
}
16+
17+
const mockHttpGet = <MockInstance<HttpGet>><any>httpGet;
18+
jest.mock('../lib/remoteProvider/httpGet', () => ({httpGet: jest.fn()}));
19+
20+
const relativeUri = process.env[ENV_CMDS_RELATIVE_URI];
21+
22+
beforeEach(() => {
23+
mockHttpGet.mockReset();
24+
process.env[ENV_CMDS_RELATIVE_URI] = '/relative/uri';
25+
});
26+
27+
afterAll(() => {
28+
process.env[ENV_CMDS_RELATIVE_URI] = relativeUri;
29+
});
30+
31+
describe('fromContainerMetadata', () => {
32+
const creds: ImdsCredentials = Object.freeze({
33+
AccessKeyId: 'foo',
34+
SecretAccessKey: 'bar',
35+
Token: 'baz',
36+
Expiration: new Date().toISOString(),
37+
});
38+
39+
it(
40+
'should reject the promise if the container credentials environment variable is not set',
41+
async () => {
42+
delete process.env[ENV_CMDS_RELATIVE_URI];
43+
await fromContainerMetadata()().then(
44+
() => { throw new Error('The promise should have been rejected'); },
45+
() => { /* promise rejected, as expected */ }
46+
);
47+
}
48+
);
49+
50+
it(
51+
'should resolve credentials by fetching them from the container metadata service',
52+
async () => {
53+
mockHttpGet.mockReturnValue(Promise.resolve(JSON.stringify(creds)));
54+
expect(await fromContainerMetadata()())
55+
.toEqual(fromImdsCredentials(creds));
56+
}
57+
);
58+
59+
it('should retry the fetching operation up to maxRetries times', async () => {
60+
const maxRetries = 5;
61+
for (let i = 0; i < maxRetries - 1; i++) {
62+
mockHttpGet.mockReturnValueOnce(Promise.reject('No!'));
63+
}
64+
mockHttpGet.mockReturnValueOnce(
65+
Promise.resolve(JSON.stringify(creds))
66+
);
67+
68+
expect(await fromContainerMetadata({maxRetries})())
69+
.toEqual(fromImdsCredentials(creds));
70+
expect(mockHttpGet.mock.calls.length).toEqual(maxRetries);
71+
});
72+
73+
it('should retry responses that receive invalid response values', async () => {
74+
for (let key of Object.keys(creds)) {
75+
const invalidCreds: any = {...creds};
76+
delete invalidCreds[key];
77+
mockHttpGet.mockReturnValueOnce(
78+
Promise.resolve(JSON.stringify(invalidCreds))
79+
);
80+
}
81+
mockHttpGet.mockReturnValueOnce(Promise.resolve(JSON.stringify(creds)));
82+
83+
await fromContainerMetadata({maxRetries: 100})();
84+
expect(mockHttpGet.mock.calls.length)
85+
.toEqual(Object.keys(creds).length + 1);
86+
});
87+
88+
it('should pass relevant configuration to httpGet', async () => {
89+
const timeout = Math.ceil(Math.random() * 1000);
90+
mockHttpGet.mockReturnValue(Promise.resolve(JSON.stringify(creds)));
91+
await fromContainerMetadata({timeout})();
92+
expect(mockHttpGet.mock.calls.length).toEqual(1);
93+
expect(mockHttpGet.mock.calls[0][0]).toEqual({
94+
host: '169.254.170.2',
95+
path: process.env[ENV_CMDS_RELATIVE_URI],
96+
timeout,
97+
});
98+
});
99+
});
Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
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(
30+
'should resolve credentials by fetching them from the container metadata service',
31+
async () => {
32+
mockHttpGet.mockReturnValue(Promise.resolve(JSON.stringify(creds)));
33+
expect(await fromInstanceMetadata({profile: 'foo'})())
34+
.toEqual(fromImdsCredentials(creds));
35+
}
36+
);
37+
38+
it('should retry the fetching operation up to maxRetries times', async () => {
39+
const maxRetries = 5;
40+
for (let i = 0; i < maxRetries - 1; i++) {
41+
mockHttpGet.mockReturnValueOnce(Promise.reject('No!'));
42+
}
43+
mockHttpGet.mockReturnValueOnce(
44+
Promise.resolve(JSON.stringify(creds))
45+
);
46+
47+
expect(await fromInstanceMetadata({maxRetries, profile: 'foo'})())
48+
.toEqual(fromImdsCredentials(creds));
49+
expect(mockHttpGet.mock.calls.length).toEqual(maxRetries);
50+
});
51+
52+
it('should retry responses that receive invalid response values', async () => {
53+
for (let key of Object.keys(creds)) {
54+
const invalidCreds: any = {...creds};
55+
delete invalidCreds[key];
56+
mockHttpGet.mockReturnValueOnce(
57+
Promise.resolve(JSON.stringify(invalidCreds))
58+
);
59+
}
60+
mockHttpGet.mockReturnValueOnce(Promise.resolve(JSON.stringify(creds)));
61+
62+
await fromInstanceMetadata({maxRetries: 100, profile: 'foo'})();
63+
expect(mockHttpGet.mock.calls.length)
64+
.toEqual(Object.keys(creds).length + 1);
65+
});
66+
67+
it('should pass relevant configuration to httpGet', async () => {
68+
const timeout = Math.ceil(Math.random() * 1000);
69+
const profile = 'foo-profile';
70+
mockHttpGet.mockReturnValue(Promise.resolve(JSON.stringify(creds)));
71+
await fromInstanceMetadata({timeout, profile})();
72+
expect(mockHttpGet.mock.calls.length).toEqual(1);
73+
expect(mockHttpGet.mock.calls[0][0]).toEqual({
74+
host: '169.254.169.254',
75+
path: `/latest/meta-data/iam/security-credentials/${profile}`,
76+
timeout,
77+
});
78+
});
79+
80+
it('should fetch the profile name if not supplied', async () => {
81+
const defaultTimeout = 1000;
82+
const profile = 'foo-profile';
83+
mockHttpGet.mockReturnValueOnce(Promise.resolve(profile));
84+
mockHttpGet.mockReturnValueOnce(Promise.resolve(JSON.stringify(creds)));
85+
86+
await fromInstanceMetadata()();
87+
expect(mockHttpGet.mock.calls.length).toEqual(2);
88+
expect(mockHttpGet.mock.calls[0][0]).toEqual({
89+
host: '169.254.169.254',
90+
path: '/latest/meta-data/iam/security-credentials/',
91+
timeout: defaultTimeout,
92+
});
93+
expect(mockHttpGet.mock.calls[1][0]).toEqual({
94+
host: '169.254.169.254',
95+
path: `/latest/meta-data/iam/security-credentials/${profile}`,
96+
timeout: defaultTimeout,
97+
});
98+
});
99+
100+
101+
102+
it('should retry the profile name fetch as necessary', async () => {
103+
const defaultTimeout = 1000;
104+
const profile = 'foo-profile';
105+
mockHttpGet.mockReturnValueOnce(Promise.reject('Too busy'));
106+
mockHttpGet.mockReturnValueOnce(Promise.resolve(profile));
107+
mockHttpGet.mockReturnValueOnce(Promise.resolve(JSON.stringify(creds)));
108+
109+
await fromInstanceMetadata()();
110+
expect(mockHttpGet.mock.calls.length).toEqual(3);
111+
expect(mockHttpGet.mock.calls[2][0]).toEqual({
112+
host: '169.254.169.254',
113+
path: `/latest/meta-data/iam/security-credentials/${profile}`,
114+
timeout: defaultTimeout,
115+
});
116+
for (let index of [0, 1]) {
117+
expect(mockHttpGet.mock.calls[index][0]).toEqual({
118+
host: '169.254.169.254',
119+
path: '/latest/meta-data/iam/security-credentials/',
120+
timeout: defaultTimeout,
121+
});
122+
}
123+
});
124+
});
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import {
2+
fromImdsCredentials,
3+
ImdsCredentials,
4+
isImdsCredentials,
5+
} from "../../lib/remoteProvider/ImdsCredentials";
6+
import {Credentials} from "../../lib/Credentials";
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(
22+
isImdsCredentials(Object.assign({}, creds, {AccessKeyId: void 0}))
23+
).toBe(false);
24+
});
25+
26+
it('should reject credentials without a SecretAccessKey', () => {
27+
expect(
28+
isImdsCredentials(Object.assign({}, creds, {SecretAccessKey: void 0}))
29+
).toBe(false);
30+
});
31+
32+
it('should reject credentials without a Token', () => {
33+
expect(
34+
isImdsCredentials(Object.assign({}, creds, {Token: void 0}))
35+
).toBe(false);
36+
});
37+
38+
it('should reject credentials without an Expiration', () => {
39+
expect(
40+
isImdsCredentials(Object.assign({}, creds, {Expiration: void 0}))
41+
).toBe(false);
42+
});
43+
44+
it('should reject scalar values', () => {
45+
for (let scalar of ['string', 1, true, null, void 0]) {
46+
expect(isImdsCredentials(scalar)).toBe(false);
47+
}
48+
});
49+
});
50+
51+
describe('fromImdsCredentials', () => {
52+
it('should convert IMDS credentials to a credentials object', () => {
53+
const converted: Credentials = fromImdsCredentials(creds);
54+
expect(converted.accessKeyId).toEqual(creds.AccessKeyId);
55+
expect(converted.secretKey).toEqual(creds.SecretAccessKey);
56+
expect(converted.sessionToken).toEqual(creds.Token);
57+
expect(converted.expiration).toEqual(
58+
Math.floor((new Date(creds.Expiration).valueOf()) / 1000)
59+
);
60+
});
61+
});
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
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}))
20+
.toEqual({timeout, maxRetries});
21+
});
22+
});
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
import {createServer} from 'http';
2+
import {httpGet} from "../../lib/remoteProvider/httpGet";
3+
4+
const matchers = new Map<string, string>();
5+
6+
function addMatcher(url: string, toReturn: string): void {
7+
matchers.set(url, toReturn);
8+
}
9+
10+
function clearMatchers(): void {
11+
matchers.clear();
12+
}
13+
14+
function getOpenPort(candidatePort: number = 4321): Promise<number> {
15+
return new Promise((resolve, reject) => {
16+
const server = createServer();
17+
server.on('error', () => reject());
18+
server.listen(candidatePort);
19+
server.close(() => resolve(candidatePort));
20+
})
21+
.catch(() => getOpenPort(candidatePort + 1));
22+
}
23+
24+
let port: number;
25+
26+
const server = createServer((request, response) => {
27+
const {url = ''} = request;
28+
if (matchers.has(url)) {
29+
response.statusCode = 200;
30+
response.end(matchers.get(url));
31+
} else {
32+
response.statusCode = 404;
33+
response.end('Not found');
34+
}
35+
});
36+
37+
beforeAll(async (done) => {
38+
port = await getOpenPort();
39+
server.listen(port);
40+
done();
41+
});
42+
43+
afterAll(() => {
44+
server.close();
45+
});
46+
47+
beforeEach(clearMatchers);
48+
49+
describe('httpGet', () => {
50+
it('should respond with a promise fulfilled with the http response', async () => {
51+
const expectedResponse = 'foo bar baz';
52+
addMatcher('/', expectedResponse);
53+
54+
expect((await httpGet(`http://localhost:${port}/`)).toString('utf8'))
55+
.toEqual(expectedResponse);
56+
});
57+
58+
it('should reject the promise if a 404 status code is received', async () => {
59+
addMatcher('/fizz', 'buzz');
60+
61+
await httpGet(`http://localhost:${port}/foo`).then(
62+
() => { throw new Error('The promise should have been rejected'); },
63+
() => { /* promise rejected, as expected */ }
64+
);
65+
});
66+
});
67+
68+

0 commit comments

Comments
 (0)