Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

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

Merged
merged 5 commits into from
Jan 19, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
@@ -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;
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading