Skip to content

Commit

Permalink
feat(credential-provider-node): use dynamic import for credential pro…
Browse files Browse the repository at this point in the history
…viders (#5677)

* feat(credential-provider-node): use dynamic import for credential providers

* test(polly-request-presigner): provide credentials to avoid credential chain

* test(aws-client-retry-test): provide credentials to avoid chain in test

* test(aws-middleware-test): update jest version

* test: mock defaultProvider in integ tests
  • Loading branch information
kuhe committed Jan 19, 2024
1 parent 3edef9c commit 7841411
Show file tree
Hide file tree
Showing 36 changed files with 827 additions and 436 deletions.
3 changes: 3 additions & 0 deletions Makefile
Expand Up @@ -20,6 +20,9 @@ unlink-smithy:
rm ./node_modules/\@smithy
yarn --check-files

copy-smithy:
node ./scripts/copy-smithy-dist-files

# Runs build for all packages using Turborepo
turbo-build:
(cd scripts/remote-cache && yarn)
Expand Down
8 changes: 4 additions & 4 deletions package.json
Expand Up @@ -69,7 +69,7 @@
"@tsconfig/recommended": "1.0.1",
"@types/chai-as-promised": "^7.1.2",
"@types/fs-extra": "^8.0.1",
"@types/jest": "29.5.2",
"@types/jest": "29.5.11",
"@typescript-eslint/eslint-plugin": "5.55.0",
"@typescript-eslint/parser": "5.55.0",
"async": "3.2.4",
Expand All @@ -93,8 +93,8 @@
"glob": "7.1.6",
"husky": "^4.2.3",
"jasmine-core": "^3.5.0",
"jest": "29.5.0",
"jest-environment-jsdom": "29.5.0",
"jest": "29.7.0",
"jest-environment-jsdom": "29.7.0",
"jmespath": "^0.15.0",
"json5": "^2.2.0",
"karma": "6.4.0",
Expand All @@ -113,7 +113,7 @@
"mocha": "10.0.0",
"prettier": "2.8.5",
"rimraf": "3.0.2",
"ts-jest": "29.1.0",
"ts-jest": "29.1.1",
"ts-loader": "9.4.2",
"ts-mocha": "10.0.0",
"ts-node": "10.9.1",
Expand Down
126 changes: 53 additions & 73 deletions packages/credential-provider-node/src/defaultProvider.spec.ts
Expand Up @@ -3,10 +3,10 @@ import { fromIni } from "@aws-sdk/credential-provider-ini";
import { fromProcess } from "@aws-sdk/credential-provider-process";
import { fromSSO } from "@aws-sdk/credential-provider-sso";
import { fromTokenFile } from "@aws-sdk/credential-provider-web-identity";
import { chain, CredentialsProviderError, memoize } from "@smithy/property-provider";
import { CredentialsProviderError } from "@smithy/property-provider";
import { ENV_PROFILE, loadSharedConfigFiles } from "@smithy/shared-ini-file-loader";

import { defaultProvider } from "./defaultProvider";
import { credentialsTreatedAsExpired, credentialsWillNeedRefresh, defaultProvider } from "./defaultProvider";
import { remoteProvider } from "./remoteProvider";

jest.mock("@aws-sdk/credential-provider-env");
Expand All @@ -15,7 +15,6 @@ jest.mock("@aws-sdk/credential-provider-ini");
jest.mock("@aws-sdk/credential-provider-process");
jest.mock("@aws-sdk/credential-provider-sso");
jest.mock("@aws-sdk/credential-provider-web-identity");
jest.mock("@smithy/property-provider");
jest.mock("@smithy/shared-ini-file-loader");
jest.mock("./remoteProvider");

Expand All @@ -25,19 +24,24 @@ describe(defaultProvider.name, () => {
secretAccessKey: "mockSecretAccessKey",
};

const credentials = () => {
throw new CredentialsProviderError("test", true);
};

const finalCredentials = () => {
return mockCreds;
};

const mockInit = {
profile: "mockProfile",
};

const mockEnvFn = jest.fn();
const mockSsoFn = jest.fn();
const mockIniFn = jest.fn();
const mockProcessFn = jest.fn();
const mockTokenFileFn = jest.fn();
const mockRemoteProviderFn = jest.fn();

const mockChainFn = jest.fn();
const mockMemoizeFn = jest.fn().mockResolvedValue(mockCreds);
const mockEnvFn = jest.fn().mockImplementation(() => credentials());
const mockSsoFn = jest.fn().mockImplementation(() => credentials());
const mockIniFn = jest.fn().mockImplementation(() => credentials());
const mockProcessFn = jest.fn().mockImplementation(() => credentials());
const mockTokenFileFn = jest.fn().mockImplementation(() => credentials());
const mockRemoteProviderFn = jest.fn().mockImplementation(() => finalCredentials());

beforeEach(() => {
[
Expand All @@ -47,51 +51,48 @@ describe(defaultProvider.name, () => {
[fromProcess, mockProcessFn],
[fromTokenFile, mockTokenFileFn],
[remoteProvider, mockRemoteProviderFn],
[chain, mockChainFn],
[memoize, mockMemoizeFn],
].forEach(([fromFn, mockFn]) => {
(fromFn as jest.Mock).mockReturnValue(mockFn);
});
});

afterEach(async () => {
const errorFnIndex = (chain as jest.Mock).mock.calls[0].length;
const errorFn = (chain as jest.Mock).mock.calls[0][errorFnIndex - 1];
const expectedError = new CredentialsProviderError("Could not load credentials from any providers", false);
try {
await errorFn();
fail(`expected ${expectedError}`);
} catch (error) {
expect(error.toString()).toStrictEqual(expectedError.toString());
}

expect(memoize).toHaveBeenCalledWith(mockChainFn, expect.any(Function), expect.any(Function));

jest.clearAllMocks();
});

describe("without fromEnv", () => {
afterEach(() => {
expect(chain).toHaveBeenCalledWith(
mockSsoFn,
mockIniFn,
mockProcessFn,
mockTokenFileFn,
mockRemoteProviderFn,
expect.any(Function)
);
});

it("creates provider chain and memoizes it", async () => {
const receivedCreds = await defaultProvider(mockInit)();
expect(receivedCreds).toStrictEqual(mockCreds);
const provider = defaultProvider(mockInit);

expect(fromEnv).not.toHaveBeenCalled();
for (const fromFn of [fromSSO, fromIni, fromProcess, fromTokenFile, remoteProvider]) {
expect(fromFn).toHaveBeenCalledWith(mockInit);
// initial call proceeds through the chain.
{
const receivedCreds = await provider();
expect(receivedCreds).toEqual(mockCreds);

expect(fromEnv).not.toHaveBeenCalled();
expect(fromSSO).toHaveBeenCalledWith(mockInit);
expect(fromIni).toHaveBeenCalledWith(mockInit);
expect(fromProcess).toHaveBeenCalledWith(mockInit);
expect(fromTokenFile).toHaveBeenCalledWith(mockInit);
expect(remoteProvider).toHaveBeenCalledWith(mockInit);

expect(loadSharedConfigFiles).not.toHaveBeenCalled();
}

expect(loadSharedConfigFiles).not.toHaveBeenCalled();
jest.clearAllMocks();

// subsequent call does not enter the chain.
{
const receivedCreds = await provider();
expect(receivedCreds).toEqual(mockCreds);

expect(fromEnv).not.toHaveBeenCalled();
expect(fromSSO).not.toHaveBeenCalledWith(mockInit);
expect(fromIni).not.toHaveBeenCalledWith(mockInit);
expect(fromProcess).not.toHaveBeenCalledWith(mockInit);
expect(fromTokenFile).not.toHaveBeenCalledWith(mockInit);
expect(remoteProvider).not.toHaveBeenCalledWith(mockInit);
}
});

it(`if env['${ENV_PROFILE}'] is set`, async () => {
Expand Down Expand Up @@ -123,63 +124,42 @@ describe(defaultProvider.name, () => {
for (const fromFn of [fromSSO, fromIni, fromProcess, fromTokenFile, remoteProvider]) {
expect(fromFn).toHaveBeenCalledWith(mockInitWithoutProfile);
}

expect(chain).toHaveBeenCalledWith(
mockEnvFn,
mockSsoFn,
mockIniFn,
mockProcessFn,
mockTokenFileFn,
mockRemoteProviderFn,
expect.any(Function)
);
});

describe("memoize isExpired", () => {
describe(credentialsTreatedAsExpired.name, () => {
const mockDateNow = Date.now();
beforeEach(async () => {
jest.spyOn(Date, "now").mockReturnValueOnce(mockDateNow);
await defaultProvider(mockInit)();
});

it("returns true if expiration is defined, and creds have expired", () => {
const memoizeExpiredFn = (memoize as jest.Mock).mock.calls[0][1];
const expiration = new Date(mockDateNow - 24 * 60 * 60 * 1000);
expect(memoizeExpiredFn({ expiration })).toEqual(true);
expect(credentialsTreatedAsExpired({ ...mockCreds, expiration })).toEqual(true);
});

it("returns true if expiration is defined, and creds expire in <5 mins", () => {
const memoizeExpiredFn = (memoize as jest.Mock).mock.calls[0][1];
const expiration = new Date(mockDateNow + 299 * 1000);
expect(memoizeExpiredFn({ expiration })).toEqual(true);
expect(credentialsTreatedAsExpired({ ...mockCreds, expiration })).toEqual(true);
});

it("returns false if expiration is defined, but creds expire in >5 mins", () => {
const memoizeExpiredFn = (memoize as jest.Mock).mock.calls[0][1];
const expiration = new Date(mockDateNow + 301 * 1000);
expect(memoizeExpiredFn({ expiration })).toEqual(false);
expect(credentialsTreatedAsExpired({ ...mockCreds, expiration })).toEqual(false);
});

it("returns false if expiration is not defined", () => {
const memoizeExpiredFn = (memoize as jest.Mock).mock.calls[0][1];
expect(memoizeExpiredFn({})).toEqual(false);
expect(credentialsTreatedAsExpired({ ...mockCreds })).toEqual(false);
});
});

describe("memoize requiresRefresh", () => {
beforeEach(async () => {
await defaultProvider(mockInit)();
});

describe(credentialsWillNeedRefresh.name, () => {
it("returns true if expiration is not defined", () => {
const memoizeRefreshFn = (memoize as jest.Mock).mock.calls[0][2];
const expiration = Date.now();
expect(memoizeRefreshFn({ expiration })).toEqual(true);
const expiration = new Date();
expect(credentialsWillNeedRefresh({ ...mockCreds, expiration })).toEqual(true);
});

it("returns false if expiration is not defined", () => {
const memoizeRefreshFn = (memoize as jest.Mock).mock.calls[0][2];
expect(memoizeRefreshFn({})).toEqual(false);
expect(credentialsWillNeedRefresh({ ...mockCreds })).toEqual(false);
});
});
});
63 changes: 49 additions & 14 deletions packages/credential-provider-node/src/defaultProvider.ts
@@ -1,9 +1,8 @@
import { fromEnv } from "@aws-sdk/credential-provider-env";
import { fromIni, FromIniInit } from "@aws-sdk/credential-provider-ini";
import { fromProcess, FromProcessInit } from "@aws-sdk/credential-provider-process";
import { fromSSO, FromSSOInit } from "@aws-sdk/credential-provider-sso";
import { fromTokenFile, FromTokenFileInit } from "@aws-sdk/credential-provider-web-identity";
import { RemoteProviderInit } from "@smithy/credential-provider-imds";
import type { FromIniInit } from "@aws-sdk/credential-provider-ini";
import type { FromProcessInit } from "@aws-sdk/credential-provider-process";
import type { FromSSOInit } from "@aws-sdk/credential-provider-sso";
import type { FromTokenFileInit } from "@aws-sdk/credential-provider-web-identity";
import type { RemoteProviderInit } from "@smithy/credential-provider-imds";
import { chain, CredentialsProviderError, memoize } from "@smithy/property-provider";
import { ENV_PROFILE } from "@smithy/shared-ini-file-loader";
import { AwsCredentialIdentity, MemoizedProvider } from "@smithy/types";
Expand Down Expand Up @@ -49,16 +48,52 @@ export type DefaultProviderInit = FromIniInit & RemoteProviderInit & FromProcess
export const defaultProvider = (init: DefaultProviderInit = {}): MemoizedProvider<AwsCredentialIdentity> =>
memoize(
chain(
...(init.profile || process.env[ENV_PROFILE] ? [] : [fromEnv()]),
fromSSO(init),
fromIni(init),
fromProcess(init),
fromTokenFile(init),
remoteProvider(init),
...(init.profile || process.env[ENV_PROFILE]
? []
: [
async () => {
const { fromEnv } = await import("@aws-sdk/credential-provider-env");
return fromEnv()();
},
]),
async () => {
const { fromSSO } = await import("@aws-sdk/credential-provider-sso");
return fromSSO(init)();
},
async () => {
const { fromIni } = await import("@aws-sdk/credential-provider-ini");
return fromIni(init)();
},
async () => {
const { fromProcess } = await import("@aws-sdk/credential-provider-process");
return fromProcess(init)();
},
async () => {
const { fromTokenFile } = await import("@aws-sdk/credential-provider-web-identity");
return fromTokenFile(init)();
},
async () => {
return (await remoteProvider(init))();
},
async () => {
throw new CredentialsProviderError("Could not load credentials from any providers", false);
}
),
(credentials) => credentials.expiration !== undefined && credentials.expiration.getTime() - Date.now() < 300000,
(credentials) => credentials.expiration !== undefined
credentialsTreatedAsExpired,
credentialsWillNeedRefresh
);

/**
* @internal
*
* @returns credentials have expiration.
*/
export const credentialsWillNeedRefresh = (credentials: AwsCredentialIdentity) => credentials?.expiration !== undefined;

/**
* @internal
*
* @returns credentials with less than 5 minutes left.
*/
export const credentialsTreatedAsExpired = (credentials: AwsCredentialIdentity) =>
credentials?.expiration !== undefined && credentials.expiration.getTime() - Date.now() < 300000;
8 changes: 5 additions & 3 deletions packages/credential-provider-node/src/remoteProvider.spec.ts
Expand Up @@ -42,7 +42,7 @@ describe(remoteProvider.name, () => {
"returns fromContainerMetadata if env['%s'] is set",
async (key) => {
process.env[key] = "defined";
const receivedCreds = await remoteProvider(mockInit)();
const receivedCreds = await (await remoteProvider(mockInit))();
expect(receivedCreds).toStrictEqual(mockCredsFromContainer);
expect(fromContainerMetadata).toHaveBeenCalledWith(mockInit);
expect(fromInstanceMetadata).not.toHaveBeenCalled();
Expand All @@ -53,7 +53,9 @@ describe(remoteProvider.name, () => {
process.env[ENV_IMDS_DISABLED] = "1";
const expectedError = new CredentialsProviderError("EC2 Instance Metadata Service access disabled");
try {
await remoteProvider(mockInit)();
await (
await remoteProvider(mockInit)
)();
fail(`expectedError ${expectedError}`);
} catch (error) {
expect(error).toStrictEqual(expectedError);
Expand All @@ -63,7 +65,7 @@ describe(remoteProvider.name, () => {
});

it("returns fromInstanceMetadata if environment variables are not set", async () => {
const receivedCreds = await remoteProvider(mockInit)();
const receivedCreds = await (await remoteProvider(mockInit))();
expect(receivedCreds).toStrictEqual(mockSourceCredsFromInstanceMetadata);
expect(fromInstanceMetadata).toHaveBeenCalledWith(mockInit);
expect(fromContainerMetadata).not.toHaveBeenCalled();
Expand Down
19 changes: 10 additions & 9 deletions packages/credential-provider-node/src/remoteProvider.ts
@@ -1,16 +1,17 @@
import {
ENV_CMDS_FULL_URI,
ENV_CMDS_RELATIVE_URI,
fromContainerMetadata,
fromInstanceMetadata,
RemoteProviderInit,
} from "@smithy/credential-provider-imds";
import type { RemoteProviderInit } from "@smithy/credential-provider-imds";
import { CredentialsProviderError } from "@smithy/property-provider";
import { AwsCredentialIdentityProvider } from "@smithy/types";
import type { AwsCredentialIdentityProvider } from "@smithy/types";

export const ENV_IMDS_DISABLED = "AWS_EC2_METADATA_DISABLED";

export const remoteProvider = (init: RemoteProviderInit): AwsCredentialIdentityProvider => {
/**
* @internal
*/
export const remoteProvider = async (init: RemoteProviderInit): Promise<AwsCredentialIdentityProvider> => {
const { ENV_CMDS_FULL_URI, ENV_CMDS_RELATIVE_URI, fromContainerMetadata, fromInstanceMetadata } = await import(
"@smithy/credential-provider-imds"
);

if (process.env[ENV_CMDS_RELATIVE_URI] || process.env[ENV_CMDS_FULL_URI]) {
return fromContainerMetadata(init);
}
Expand Down
@@ -1,3 +1,12 @@
jest.mock("@aws-sdk/credential-provider-node", () => ({
defaultProvider: async () => {
return {
secretAccessKey: "integration-test",
accessKeyId: "integration-test",
sessionToken: "integration-test",
};
},
}));
import { TimestreamQuery } from "@aws-sdk/client-timestream-query";
import { TimestreamWrite } from "@aws-sdk/client-timestream-write";
import { EndpointCache } from "@aws-sdk/endpoint-cache";
Expand Down

0 comments on commit 7841411

Please sign in to comment.