Skip to content

Commit a4beeba

Browse files
feat(credential-provider-imds): support static stability (#3402)
* feat(credential-provider-imds): support static stability * fix(credential-provider-imds): remove static stability for container provider * feat(credential-provider-imds): add jitter to static stable refresh interval Co-authored-by: Trivikram Kamat <[email protected]>
1 parent 813ff8a commit a4beeba

9 files changed

+221
-47
lines changed

packages/credential-provider-imds/src/fromContainerMetadata.spec.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import { httpRequest } from "./remoteProvider/httpRequest";
88
import { fromImdsCredentials, ImdsCredentials } from "./remoteProvider/ImdsCredentials";
99

1010
const mockHttpRequest = <any>httpRequest;
11-
jest.mock("./remoteProvider/httpRequest", () => ({ httpRequest: jest.fn() }));
11+
jest.mock("./remoteProvider/httpRequest");
1212

1313
const relativeUri = process.env[ENV_CMDS_RELATIVE_URI];
1414
const fullUri = process.env[ENV_CMDS_FULL_URI];

packages/credential-provider-imds/src/fromInstanceMetadata.spec.ts

Lines changed: 18 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,14 @@ import { fromImdsCredentials, isImdsCredentials } from "./remoteProvider/ImdsCre
66
import { providerConfigFromInit } from "./remoteProvider/RemoteProviderInit";
77
import { retry } from "./remoteProvider/retry";
88
import { getInstanceMetadataEndpoint } from "./utils/getInstanceMetadataEndpoint";
9+
import { staticStabilityProvider } from "./utils/staticStabilityProvider";
910

1011
jest.mock("./remoteProvider/httpRequest");
1112
jest.mock("./remoteProvider/ImdsCredentials");
1213
jest.mock("./remoteProvider/retry");
1314
jest.mock("./remoteProvider/RemoteProviderInit");
1415
jest.mock("./utils/getInstanceMetadataEndpoint");
16+
jest.mock("./utils/staticStabilityProvider");
1517

1618
describe("fromInstanceMetadata", () => {
1719
const hostname = "127.0.0.1";
@@ -39,11 +41,12 @@ describe("fromInstanceMetadata", () => {
3941
},
4042
};
4143

44+
const ONE_HOUR_IN_FUTURE = new Date(Date.now() + 60 * 60 * 1000);
4245
const mockImdsCreds = Object.freeze({
4346
AccessKeyId: "foo",
4447
SecretAccessKey: "bar",
4548
Token: "baz",
46-
Expiration: new Date().toISOString(),
49+
Expiration: ONE_HOUR_IN_FUTURE.toISOString(),
4750
});
4851

4952
const mockCreds = Object.freeze({
@@ -54,6 +57,7 @@ describe("fromInstanceMetadata", () => {
5457
});
5558

5659
beforeEach(() => {
60+
(staticStabilityProvider as jest.Mock).mockImplementation((input) => input);
5761
(getInstanceMetadataEndpoint as jest.Mock).mockResolvedValue({ hostname });
5862
(isImdsCredentials as unknown as jest.Mock).mockReturnValue(true);
5963
(providerConfigFromInit as jest.Mock).mockReturnValue({
@@ -192,6 +196,19 @@ describe("fromInstanceMetadata", () => {
192196
await expect(fromInstanceMetadata()()).rejects.toEqual(tokenError);
193197
});
194198

199+
it("should call staticStabilityProvider with the credential loader", async () => {
200+
(httpRequest as jest.Mock)
201+
.mockResolvedValueOnce(mockToken)
202+
.mockResolvedValueOnce(mockProfile)
203+
.mockResolvedValueOnce(JSON.stringify(mockImdsCreds));
204+
205+
(retry as jest.Mock).mockImplementation((fn: any) => fn());
206+
(fromImdsCredentials as jest.Mock).mockReturnValue(mockCreds);
207+
208+
await fromInstanceMetadata()();
209+
expect(staticStabilityProvider as jest.Mock).toBeCalledTimes(1);
210+
});
211+
195212
describe("disables fetching of token", () => {
196213
beforeEach(() => {
197214
(retry as jest.Mock).mockImplementation((fn: any) => fn());
@@ -268,47 +285,4 @@ describe("fromInstanceMetadata", () => {
268285
await expect(fromInstanceMetadataFunc()).resolves.toEqual(mockCreds);
269286
await expect(fromInstanceMetadataFunc()).resolves.toEqual(mockCreds);
270287
});
271-
272-
describe("re-enables fetching of token", () => {
273-
const error401 = Object.assign(new Error("error"), { statusCode: 401 });
274-
275-
beforeEach(() => {
276-
const tokenError = new Error("TimeoutError");
277-
278-
(httpRequest as jest.Mock)
279-
.mockRejectedValueOnce(tokenError)
280-
.mockResolvedValueOnce(mockProfile)
281-
.mockResolvedValueOnce(JSON.stringify(mockImdsCreds));
282-
283-
(retry as jest.Mock).mockImplementation((fn: any) => fn());
284-
(fromImdsCredentials as jest.Mock).mockReturnValue(mockCreds);
285-
});
286-
287-
it("when profile error with 401", async () => {
288-
(httpRequest as jest.Mock)
289-
.mockRejectedValueOnce(error401)
290-
.mockResolvedValueOnce(mockToken)
291-
.mockResolvedValueOnce(mockProfile)
292-
.mockResolvedValueOnce(JSON.stringify(mockImdsCreds));
293-
294-
const fromInstanceMetadataFunc = fromInstanceMetadata();
295-
await expect(fromInstanceMetadataFunc()).resolves.toEqual(mockCreds);
296-
await expect(fromInstanceMetadataFunc()).rejects.toEqual(error401);
297-
await expect(fromInstanceMetadataFunc()).resolves.toEqual(mockCreds);
298-
});
299-
300-
it("when creds error with 401", async () => {
301-
(httpRequest as jest.Mock)
302-
.mockResolvedValueOnce(mockProfile)
303-
.mockRejectedValueOnce(error401)
304-
.mockResolvedValueOnce(mockToken)
305-
.mockResolvedValueOnce(mockProfile)
306-
.mockResolvedValueOnce(JSON.stringify(mockImdsCreds));
307-
308-
const fromInstanceMetadataFunc = fromInstanceMetadata();
309-
await expect(fromInstanceMetadataFunc()).resolves.toEqual(mockCreds);
310-
await expect(fromInstanceMetadataFunc()).rejects.toEqual(error401);
311-
await expect(fromInstanceMetadataFunc()).resolves.toEqual(mockCreds);
312-
});
313-
});
314288
});

packages/credential-provider-imds/src/fromInstanceMetadata.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
11
import { CredentialsProviderError } from "@aws-sdk/property-provider";
2-
import { CredentialProvider, Credentials } from "@aws-sdk/types";
2+
import { Credentials, Provider } from "@aws-sdk/types";
33
import { RequestOptions } from "http";
44

55
import { httpRequest } from "./remoteProvider/httpRequest";
66
import { fromImdsCredentials, isImdsCredentials } from "./remoteProvider/ImdsCredentials";
77
import { providerConfigFromInit, RemoteProviderInit } from "./remoteProvider/RemoteProviderInit";
88
import { retry } from "./remoteProvider/retry";
9+
import { InstanceMetadataCredentials } from "./types";
910
import { getInstanceMetadataEndpoint } from "./utils/getInstanceMetadataEndpoint";
11+
import { staticStabilityProvider } from "./utils/staticStabilityProvider";
1012

1113
const IMDS_PATH = "/latest/meta-data/iam/security-credentials/";
1214
const IMDS_TOKEN_PATH = "/latest/api/token";
@@ -15,7 +17,10 @@ const IMDS_TOKEN_PATH = "/latest/api/token";
1517
* Creates a credential provider that will source credentials from the EC2
1618
* Instance Metadata Service
1719
*/
18-
export const fromInstanceMetadata = (init: RemoteProviderInit = {}): CredentialProvider => {
20+
export const fromInstanceMetadata = (init: RemoteProviderInit = {}): Provider<InstanceMetadataCredentials> =>
21+
staticStabilityProvider(getInstanceImdsProvider(init));
22+
23+
const getInstanceImdsProvider = (init: RemoteProviderInit) => {
1924
// when set to true, metadata service will not fetch token
2025
let disableFetchToken = false;
2126
const { timeout, maxRetries } = providerConfigFromInit(init);
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
export * from "./fromContainerMetadata";
22
export * from "./fromInstanceMetadata";
33
export * from "./remoteProvider/RemoteProviderInit";
4+
export * from "./types";
45
export { httpRequest } from "./remoteProvider/httpRequest";
56
export { getInstanceMetadataEndpoint } from "./utils/getInstanceMetadataEndpoint";
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
import { Credentials } from "@aws-sdk/types";
2+
3+
export interface InstanceMetadataCredentials extends Credentials {
4+
readonly originalExpiration?: Date;
5+
}
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import { getExtendedInstanceMetadataCredentials } from "./getExtendedInstanceMetadataCredentials";
2+
3+
describe("getExtendedInstanceMetadataCredentials()", () => {
4+
let nowMock: jest.SpyInstance;
5+
const staticSecret = {
6+
accessKeyId: "key",
7+
secretAccessKey: "secret",
8+
};
9+
10+
beforeEach(() => {
11+
jest.spyOn(global.console, "warn").mockImplementation(() => {});
12+
jest.spyOn(global.Math, "random");
13+
nowMock = jest.spyOn(Date, "now").mockReturnValueOnce(new Date("2022-02-22T00:00:00Z").getTime());
14+
});
15+
16+
afterEach(() => {
17+
nowMock.mockRestore();
18+
});
19+
20+
it("should extend the expiration random time(~15 mins) from now", () => {
21+
const anyDate: Date = "any date" as unknown as Date;
22+
(Math.random as jest.Mock).mockReturnValue(0.5);
23+
expect(getExtendedInstanceMetadataCredentials({ ...staticSecret, expiration: anyDate })).toEqual({
24+
...staticSecret,
25+
originalExpiration: anyDate,
26+
expiration: new Date("2022-02-22T00:17:30Z"),
27+
});
28+
expect(Math.random).toBeCalledTimes(1);
29+
});
30+
31+
it("should print warning message when extending the credentials", () => {
32+
const anyDate: Date = "any date" as unknown as Date;
33+
getExtendedInstanceMetadataCredentials({ ...staticSecret, expiration: anyDate });
34+
// TODO: fill the doc link
35+
expect(console.warn).toBeCalledWith(expect.stringContaining("Attempting credential expiration extension"));
36+
});
37+
});
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import { InstanceMetadataCredentials } from "../types";
2+
3+
const STATIC_STABILITY_REFRESH_INTERVAL_SECONDS = 15 * 60;
4+
const STATIC_STABILITY_REFRESH_INTERVAL_JITTER_WINDOW_SECONDS = 5 * 60;
5+
// TODO
6+
const STATIC_STABILITY_DOC_URL = "https://docs.aws.amazon.com/sdkref/latest/guide/feature-static-credentials.html";
7+
8+
export const getExtendedInstanceMetadataCredentials = (
9+
credentials: InstanceMetadataCredentials
10+
): InstanceMetadataCredentials => {
11+
const refreshInterval =
12+
STATIC_STABILITY_REFRESH_INTERVAL_SECONDS +
13+
Math.floor(Math.random() * STATIC_STABILITY_REFRESH_INTERVAL_JITTER_WINDOW_SECONDS);
14+
const newExpiration = new Date(Date.now() + refreshInterval * 1000);
15+
// ToDo: Call warn function on logger from configuration
16+
console.warn(
17+
"Attempting credential expiration extension due to a credential service availability issue. A refresh of these " +
18+
"credentials will be attempted after ${new Date(newExpiration)}.\nFor more information, please visit: " +
19+
STATIC_STABILITY_DOC_URL
20+
);
21+
const originalExpiration = credentials.originalExpiration ?? credentials.expiration;
22+
return {
23+
...credentials,
24+
...(originalExpiration ? { originalExpiration } : {}),
25+
expiration: newExpiration,
26+
};
27+
};
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
import { getExtendedInstanceMetadataCredentials } from "./getExtendedInstanceMetadataCredentials";
2+
import { staticStabilityProvider } from "./staticStabilityProvider";
3+
4+
jest.mock("./getExtendedInstanceMetadataCredentials");
5+
6+
describe("staticStabilityProvider", () => {
7+
const ONE_HOUR_IN_FUTURE = new Date(Date.now() + 60 * 60 * 1000);
8+
const mockCreds = {
9+
accessKeyId: "key",
10+
secretAccessKey: "secret",
11+
sessionToken: "settion",
12+
expiration: ONE_HOUR_IN_FUTURE,
13+
};
14+
15+
beforeEach(() => {
16+
(getExtendedInstanceMetadataCredentials as jest.Mock).mockImplementation(
17+
(() => {
18+
let extensionCount = 0;
19+
return (input) => {
20+
extensionCount++;
21+
return {
22+
...input,
23+
expiration: `Extending expiration count: ${extensionCount}`,
24+
};
25+
};
26+
})()
27+
);
28+
jest.spyOn(global.console, "warn").mockImplementation(() => {});
29+
});
30+
31+
afterEach(() => {
32+
jest.resetAllMocks();
33+
});
34+
35+
it("should refresh credentials if provider is functional", async () => {
36+
const provider = jest.fn();
37+
const stableProvider = staticStabilityProvider(provider);
38+
const repeat = 3;
39+
for (let i = 0; i < repeat; i++) {
40+
const newCreds = { ...mockCreds, accessKeyId: String(i + 1) };
41+
provider.mockReset().mockResolvedValue(newCreds);
42+
expect(await stableProvider()).toEqual(newCreds);
43+
}
44+
});
45+
46+
it("should throw if cannot load credentials at 1st load", async () => {
47+
const provider = jest.fn().mockRejectedValue("Error");
48+
try {
49+
await staticStabilityProvider(provider)();
50+
fail("This provider should throw");
51+
} catch (e) {
52+
expect(getExtendedInstanceMetadataCredentials).not.toBeCalled();
53+
expect(provider).toBeCalledTimes(1);
54+
expect(e).toEqual("Error");
55+
}
56+
});
57+
58+
it("should extend expired credentials if refresh fails", async () => {
59+
const provider = jest.fn().mockResolvedValueOnce(mockCreds).mockRejectedValue("Error");
60+
const stableProvider = staticStabilityProvider(provider);
61+
expect(await stableProvider()).toEqual(mockCreds);
62+
const repeat = 3;
63+
for (let i = 0; i < repeat; i++) {
64+
const newCreds = await stableProvider();
65+
expect(newCreds).toMatchObject({ ...mockCreds, expiration: expect.stringContaining(`count: ${i + 1}`) });
66+
expect(console.warn).toHaveBeenLastCalledWith(
67+
expect.stringContaining("Credential renew failed:"),
68+
expect.anything()
69+
);
70+
}
71+
expect(getExtendedInstanceMetadataCredentials).toBeCalledTimes(repeat);
72+
expect(console.warn).toBeCalledTimes(repeat);
73+
});
74+
75+
it("should extend expired credentials if loaded expired credentials", async () => {
76+
const ONE_HOUR_AGO = new Date(Date.now() - 60 * 60 * 1000);
77+
const provider = jest.fn().mockResolvedValue({ ...mockCreds, expiration: ONE_HOUR_AGO });
78+
const stableProvider = staticStabilityProvider(provider);
79+
const repeat = 3;
80+
for (let i = 0; i < repeat; i++) {
81+
const newCreds = await stableProvider();
82+
expect(newCreds).toMatchObject({ ...mockCreds, expiration: expect.stringContaining(`count: ${i + 1}`) });
83+
}
84+
expect(getExtendedInstanceMetadataCredentials).toBeCalledTimes(repeat);
85+
expect(console.warn).not.toBeCalled();
86+
});
87+
});
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import { Credentials, Provider } from "@aws-sdk/types";
2+
3+
import { InstanceMetadataCredentials } from "../types";
4+
import { getExtendedInstanceMetadataCredentials } from "./getExtendedInstanceMetadataCredentials";
5+
6+
/**
7+
* IMDS credential supports static stability feature. When used, the expiration
8+
* of recently issued credentials is extended. The server side allows using
9+
* the recently expired credentials. This mitigates impact when clients using
10+
* refreshable credentials are unable to retrieve updates.
11+
*
12+
* @param provider Credential provider
13+
* @returns A credential provider that supports static stability
14+
*/
15+
export const staticStabilityProvider = (
16+
provider: Provider<InstanceMetadataCredentials>
17+
): Provider<InstanceMetadataCredentials> => {
18+
let pastCredentials: InstanceMetadataCredentials;
19+
return async () => {
20+
let credentials: InstanceMetadataCredentials;
21+
try {
22+
credentials = await provider();
23+
if (credentials.expiration && credentials.expiration.getTime() < Date.now()) {
24+
credentials = getExtendedInstanceMetadataCredentials(credentials);
25+
}
26+
} catch (e) {
27+
if (pastCredentials) {
28+
// ToDo: Call warn function on logger from configuration
29+
console.warn("Credential renew failed: ", e);
30+
credentials = getExtendedInstanceMetadataCredentials(pastCredentials);
31+
} else {
32+
throw e;
33+
}
34+
}
35+
pastCredentials = credentials;
36+
return credentials;
37+
};
38+
};

0 commit comments

Comments
 (0)