Skip to content

Commit 5f351cd

Browse files
authored
feat(credential-provider-node): refactor into modular components (#3294)
1 parent 030da71 commit 5f351cd

File tree

6 files changed

+379
-709
lines changed

6 files changed

+379
-709
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,208 @@
1+
import { fromEnv } from "@aws-sdk/credential-provider-env";
2+
import { RemoteProviderInit } from "@aws-sdk/credential-provider-imds";
3+
import { fromIni, FromIniInit } from "@aws-sdk/credential-provider-ini";
4+
import { fromProcess, FromProcessInit } from "@aws-sdk/credential-provider-process";
5+
import { fromSSO, FromSSOInit } from "@aws-sdk/credential-provider-sso";
6+
import { fromTokenFile, FromTokenFileInit } from "@aws-sdk/credential-provider-web-identity";
7+
import { chain, CredentialsProviderError, memoize } from "@aws-sdk/property-provider";
8+
import { loadSharedConfigFiles } from "@aws-sdk/shared-ini-file-loader";
9+
import { CredentialProvider } from "@aws-sdk/types";
10+
import { ENV_PROFILE } from "@aws-sdk/util-credentials";
11+
12+
import { defaultProvider } from "./defaultProvider";
13+
import { remoteProvider } from "./remoteProvider";
14+
15+
jest.mock("@aws-sdk/credential-provider-env");
16+
jest.mock("@aws-sdk/credential-provider-imds");
17+
jest.mock("@aws-sdk/credential-provider-ini");
18+
jest.mock("@aws-sdk/credential-provider-process");
19+
jest.mock("@aws-sdk/credential-provider-sso");
20+
jest.mock("@aws-sdk/credential-provider-web-identity");
21+
jest.mock("@aws-sdk/property-provider");
22+
jest.mock("@aws-sdk/shared-ini-file-loader");
23+
jest.mock("./remoteProvider");
24+
25+
describe(defaultProvider.name, () => {
26+
const mockCreds = {
27+
accessKeyId: "mockAccessKeyId",
28+
secretAccessKey: "mockSecretAccessKey",
29+
};
30+
31+
const mockInit = {
32+
profile: "mockProfile",
33+
loadedConfig: Promise.resolve({ configFile: {}, credentialsFile: {} }),
34+
};
35+
36+
const mockEnvFn = jest.fn();
37+
const mockSsoFn = jest.fn();
38+
const mockIniFn = jest.fn();
39+
const mockProcessFn = jest.fn();
40+
const mockTokenFileFn = jest.fn();
41+
const mockRemoteProviderFn = jest.fn();
42+
43+
const mockChainFn = jest.fn();
44+
const mockMemoizeFn = jest.fn().mockResolvedValue(mockCreds);
45+
46+
beforeEach(() => {
47+
[
48+
[fromEnv, mockEnvFn],
49+
[fromSSO, mockSsoFn],
50+
[fromIni, mockIniFn],
51+
[fromProcess, mockProcessFn],
52+
[fromTokenFile, mockTokenFileFn],
53+
[remoteProvider, mockRemoteProviderFn],
54+
[chain, mockChainFn],
55+
[memoize, mockMemoizeFn],
56+
].forEach(([fromFn, mockFn]) => {
57+
(fromFn as jest.Mock).mockReturnValue(mockFn);
58+
});
59+
});
60+
61+
afterEach(async () => {
62+
const errorFnIndex = (chain as jest.Mock).mock.calls[0].length;
63+
const errorFn = (chain as jest.Mock).mock.calls[0][errorFnIndex - 1];
64+
const expectedError = new CredentialsProviderError("Could not load credentials from any providers", false);
65+
try {
66+
await errorFn();
67+
fail(`expected ${expectedError}`);
68+
} catch (error) {
69+
expect(error).toStrictEqual(expectedError);
70+
}
71+
72+
expect(memoize).toHaveBeenCalledWith(mockChainFn, expect.any(Function), expect.any(Function));
73+
74+
jest.clearAllMocks();
75+
});
76+
77+
describe("without fromEnv", () => {
78+
afterEach(() => {
79+
expect(chain).toHaveBeenCalledWith(
80+
mockSsoFn,
81+
mockIniFn,
82+
mockProcessFn,
83+
mockTokenFileFn,
84+
mockRemoteProviderFn,
85+
expect.any(Function)
86+
);
87+
});
88+
89+
it("creates provider chain and memoizes it", async () => {
90+
const receivedCreds = await defaultProvider(mockInit)();
91+
expect(receivedCreds).toStrictEqual(mockCreds);
92+
93+
expect(fromEnv).not.toHaveBeenCalled();
94+
for (const fromFn of [fromSSO, fromIni, fromProcess, fromTokenFile, remoteProvider]) {
95+
expect(fromFn).toHaveBeenCalledWith(mockInit);
96+
}
97+
98+
expect(loadSharedConfigFiles).not.toHaveBeenCalled();
99+
});
100+
101+
it(`reads profile from env['${ENV_PROFILE}'], if not provided in init`, async () => {
102+
const ORIGINAL_ENV = process.env;
103+
process.env = {
104+
...ORIGINAL_ENV,
105+
[ENV_PROFILE]: "envProfile",
106+
};
107+
108+
const { profile, ...mockInitWithoutProfile } = mockInit;
109+
const receivedCreds = await defaultProvider(mockInitWithoutProfile)();
110+
expect(receivedCreds).toStrictEqual(mockCreds);
111+
112+
expect(fromEnv).not.toHaveBeenCalled();
113+
for (const fromFn of [fromSSO, fromIni, fromProcess, fromTokenFile, remoteProvider]) {
114+
expect(fromFn).toHaveBeenCalledWith({ ...mockInit, profile: process.env[ENV_PROFILE] });
115+
}
116+
117+
process.env = ORIGINAL_ENV;
118+
});
119+
120+
it(`gets loadedConfig from loadSharedConfigFiles, if not provided in init`, async () => {
121+
const mockSharedConfigFiles = Promise.resolve({
122+
configFile: { key: "value" },
123+
credentialsFile: { key: "value" },
124+
});
125+
(loadSharedConfigFiles as jest.Mock).mockReturnValue(mockSharedConfigFiles);
126+
127+
const { loadedConfig, ...mockInitWithoutLoadedConfig } = mockInit;
128+
const receivedCreds = await defaultProvider(mockInitWithoutLoadedConfig)();
129+
expect(receivedCreds).toStrictEqual(mockCreds);
130+
131+
expect(loadSharedConfigFiles).toHaveBeenCalledWith(mockInitWithoutLoadedConfig);
132+
133+
expect(fromEnv).not.toHaveBeenCalled();
134+
for (const fromFn of [fromSSO, fromIni, fromProcess, fromTokenFile, remoteProvider]) {
135+
expect(fromFn).toHaveBeenCalledWith({ ...mockInit, loadedConfig: mockSharedConfigFiles });
136+
}
137+
});
138+
});
139+
140+
it(`adds fromEnv call if profile is not available`, async () => {
141+
const { profile, ...mockInitWithoutProfile } = mockInit;
142+
const receivedCreds = await defaultProvider(mockInitWithoutProfile)();
143+
expect(receivedCreds).toStrictEqual(mockCreds);
144+
145+
expect(fromEnv).toHaveBeenCalledTimes(1);
146+
for (const fromFn of [fromSSO, fromIni, fromProcess, fromTokenFile, remoteProvider]) {
147+
expect(fromFn).toHaveBeenCalledWith(mockInitWithoutProfile);
148+
}
149+
150+
expect(chain).toHaveBeenCalledWith(
151+
mockEnvFn,
152+
mockSsoFn,
153+
mockIniFn,
154+
mockProcessFn,
155+
mockTokenFileFn,
156+
mockRemoteProviderFn,
157+
expect.any(Function)
158+
);
159+
});
160+
161+
describe("memoize isExpired", () => {
162+
const mockDateNow = Date.now();
163+
beforeEach(async () => {
164+
jest.spyOn(Date, "now").mockReturnValueOnce(mockDateNow);
165+
await defaultProvider(mockInit)();
166+
});
167+
168+
it("returns true if expiration is defined, and creds have expired", () => {
169+
const memoizeExpiredFn = (memoize as jest.Mock).mock.calls[0][1];
170+
const expiration = new Date(mockDateNow - 24 * 60 * 60 * 1000);
171+
expect(memoizeExpiredFn({ expiration })).toEqual(true);
172+
});
173+
174+
it("returns true if expiration is defined, and creds expire in <5 mins", () => {
175+
const memoizeExpiredFn = (memoize as jest.Mock).mock.calls[0][1];
176+
const expiration = new Date(mockDateNow + 299 * 1000);
177+
expect(memoizeExpiredFn({ expiration })).toEqual(true);
178+
});
179+
180+
it("returns false if expiration is defined, but creds expire in >5 mins", () => {
181+
const memoizeExpiredFn = (memoize as jest.Mock).mock.calls[0][1];
182+
const expiration = new Date(mockDateNow + 301 * 1000);
183+
expect(memoizeExpiredFn({ expiration })).toEqual(false);
184+
});
185+
186+
it("returns false if expiration is not defined", () => {
187+
const memoizeExpiredFn = (memoize as jest.Mock).mock.calls[0][1];
188+
expect(memoizeExpiredFn({})).toEqual(false);
189+
});
190+
});
191+
192+
describe("memoize requiresRefresh", () => {
193+
beforeEach(async () => {
194+
await defaultProvider(mockInit)();
195+
});
196+
197+
it("returns true if expiration is not defined", () => {
198+
const memoizeRefreshFn = (memoize as jest.Mock).mock.calls[0][2];
199+
const expiration = Date.now();
200+
expect(memoizeRefreshFn({ expiration })).toEqual(true);
201+
});
202+
203+
it("returns false if expiration is not defined", () => {
204+
const memoizeRefreshFn = (memoize as jest.Mock).mock.calls[0][2];
205+
expect(memoizeRefreshFn({})).toEqual(false);
206+
});
207+
});
208+
});
+74
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import { fromEnv } from "@aws-sdk/credential-provider-env";
2+
import { RemoteProviderInit } from "@aws-sdk/credential-provider-imds";
3+
import { fromIni, FromIniInit } from "@aws-sdk/credential-provider-ini";
4+
import { fromProcess, FromProcessInit } from "@aws-sdk/credential-provider-process";
5+
import { fromSSO, FromSSOInit } from "@aws-sdk/credential-provider-sso";
6+
import { fromTokenFile, FromTokenFileInit } from "@aws-sdk/credential-provider-web-identity";
7+
import { chain, CredentialsProviderError, memoize } from "@aws-sdk/property-provider";
8+
import { loadSharedConfigFiles } from "@aws-sdk/shared-ini-file-loader";
9+
import { CredentialProvider } from "@aws-sdk/types";
10+
import { ENV_PROFILE } from "@aws-sdk/util-credentials";
11+
12+
import { remoteProvider } from "./remoteProvider";
13+
14+
/**
15+
* Creates a credential provider that will attempt to find credentials from the
16+
* following sources (listed in order of precedence):
17+
* * Environment variables exposed via `process.env`
18+
* * SSO credentials from token cache
19+
* * Web identity token credentials
20+
* * Shared credentials and config ini files
21+
* * The EC2/ECS Instance Metadata Service
22+
*
23+
* The default credential provider will invoke one provider at a time and only
24+
* continue to the next if no credentials have been located. For example, if
25+
* the process finds values defined via the `AWS_ACCESS_KEY_ID` and
26+
* `AWS_SECRET_ACCESS_KEY` environment variables, the files at
27+
* `~/.aws/credentials` and `~/.aws/config` will not be read, nor will any
28+
* messages be sent to the Instance Metadata Service.
29+
*
30+
* @param init Configuration that is passed to each individual
31+
* provider
32+
*
33+
* @see fromEnv The function used to source credentials from
34+
* environment variables
35+
* @see fromSSO The function used to source credentials from
36+
* resolved SSO token cache
37+
* @see fromTokenFile The function used to source credentials from
38+
* token file
39+
* @see fromIni The function used to source credentials from INI
40+
* files
41+
* @see fromProcess The function used to sources credentials from
42+
* credential_process in INI files
43+
* @see fromInstanceMetadata The function used to source credentials from the
44+
* EC2 Instance Metadata Service
45+
* @see fromContainerMetadata The function used to source credentials from the
46+
* ECS Container Metadata Service
47+
*/
48+
export const defaultProvider = (
49+
init: FromIniInit & RemoteProviderInit & FromProcessInit & FromSSOInit & FromTokenFileInit = {}
50+
): CredentialProvider => {
51+
const options = {
52+
profile: process.env[ENV_PROFILE],
53+
...init,
54+
...(!init.loadedConfig && { loadedConfig: loadSharedConfigFiles(init) }),
55+
};
56+
57+
const providerChain = chain(
58+
...(options.profile ? [] : [fromEnv()]),
59+
fromSSO(options),
60+
fromIni(options),
61+
fromProcess(options),
62+
fromTokenFile(options),
63+
remoteProvider(options),
64+
async () => {
65+
throw new CredentialsProviderError("Could not load credentials from any providers", false);
66+
}
67+
);
68+
69+
return memoize(
70+
providerChain,
71+
(credentials) => credentials.expiration !== undefined && credentials.expiration.getTime() - Date.now() < 300000,
72+
(credentials) => credentials.expiration !== undefined
73+
);
74+
};

0 commit comments

Comments
 (0)