Skip to content

Commit

Permalink
feat(credential-provider-node): refactor into modular components (#3294)
Browse files Browse the repository at this point in the history
  • Loading branch information
trivikr authored Feb 14, 2022
1 parent 030da71 commit 5f351cd
Show file tree
Hide file tree
Showing 6 changed files with 379 additions and 709 deletions.
208 changes: 208 additions & 0 deletions packages/credential-provider-node/src/defaultProvider.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,208 @@
import { fromEnv } from "@aws-sdk/credential-provider-env";
import { RemoteProviderInit } from "@aws-sdk/credential-provider-imds";
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 { chain, CredentialsProviderError, memoize } from "@aws-sdk/property-provider";
import { loadSharedConfigFiles } from "@aws-sdk/shared-ini-file-loader";
import { CredentialProvider } from "@aws-sdk/types";
import { ENV_PROFILE } from "@aws-sdk/util-credentials";

import { defaultProvider } from "./defaultProvider";
import { remoteProvider } from "./remoteProvider";

jest.mock("@aws-sdk/credential-provider-env");
jest.mock("@aws-sdk/credential-provider-imds");
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("@aws-sdk/property-provider");
jest.mock("@aws-sdk/shared-ini-file-loader");
jest.mock("./remoteProvider");

describe(defaultProvider.name, () => {
const mockCreds = {
accessKeyId: "mockAccessKeyId",
secretAccessKey: "mockSecretAccessKey",
};

const mockInit = {
profile: "mockProfile",
loadedConfig: Promise.resolve({ configFile: {}, credentialsFile: {} }),
};

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);

beforeEach(() => {
[
[fromEnv, mockEnvFn],
[fromSSO, mockSsoFn],
[fromIni, mockIniFn],
[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).toStrictEqual(expectedError);
}

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);

expect(fromEnv).not.toHaveBeenCalled();
for (const fromFn of [fromSSO, fromIni, fromProcess, fromTokenFile, remoteProvider]) {
expect(fromFn).toHaveBeenCalledWith(mockInit);
}

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

it(`reads profile from env['${ENV_PROFILE}'], if not provided in init`, async () => {
const ORIGINAL_ENV = process.env;
process.env = {
...ORIGINAL_ENV,
[ENV_PROFILE]: "envProfile",
};

const { profile, ...mockInitWithoutProfile } = mockInit;
const receivedCreds = await defaultProvider(mockInitWithoutProfile)();
expect(receivedCreds).toStrictEqual(mockCreds);

expect(fromEnv).not.toHaveBeenCalled();
for (const fromFn of [fromSSO, fromIni, fromProcess, fromTokenFile, remoteProvider]) {
expect(fromFn).toHaveBeenCalledWith({ ...mockInit, profile: process.env[ENV_PROFILE] });
}

process.env = ORIGINAL_ENV;
});

it(`gets loadedConfig from loadSharedConfigFiles, if not provided in init`, async () => {
const mockSharedConfigFiles = Promise.resolve({
configFile: { key: "value" },
credentialsFile: { key: "value" },
});
(loadSharedConfigFiles as jest.Mock).mockReturnValue(mockSharedConfigFiles);

const { loadedConfig, ...mockInitWithoutLoadedConfig } = mockInit;
const receivedCreds = await defaultProvider(mockInitWithoutLoadedConfig)();
expect(receivedCreds).toStrictEqual(mockCreds);

expect(loadSharedConfigFiles).toHaveBeenCalledWith(mockInitWithoutLoadedConfig);

expect(fromEnv).not.toHaveBeenCalled();
for (const fromFn of [fromSSO, fromIni, fromProcess, fromTokenFile, remoteProvider]) {
expect(fromFn).toHaveBeenCalledWith({ ...mockInit, loadedConfig: mockSharedConfigFiles });
}
});
});

it(`adds fromEnv call if profile is not available`, async () => {
const { profile, ...mockInitWithoutProfile } = mockInit;
const receivedCreds = await defaultProvider(mockInitWithoutProfile)();
expect(receivedCreds).toStrictEqual(mockCreds);

expect(fromEnv).toHaveBeenCalledTimes(1);
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", () => {
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);
});

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);
});

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);
});

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

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

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);
});

it("returns false if expiration is not defined", () => {
const memoizeRefreshFn = (memoize as jest.Mock).mock.calls[0][2];
expect(memoizeRefreshFn({})).toEqual(false);
});
});
});
74 changes: 74 additions & 0 deletions packages/credential-provider-node/src/defaultProvider.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { fromEnv } from "@aws-sdk/credential-provider-env";
import { RemoteProviderInit } from "@aws-sdk/credential-provider-imds";
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 { chain, CredentialsProviderError, memoize } from "@aws-sdk/property-provider";
import { loadSharedConfigFiles } from "@aws-sdk/shared-ini-file-loader";
import { CredentialProvider } from "@aws-sdk/types";
import { ENV_PROFILE } from "@aws-sdk/util-credentials";

import { remoteProvider } from "./remoteProvider";

/**
* Creates a credential provider that will attempt to find credentials from the
* following sources (listed in order of precedence):
* * Environment variables exposed via `process.env`
* * SSO credentials from token cache
* * Web identity token credentials
* * Shared credentials and config ini files
* * The EC2/ECS Instance Metadata Service
*
* The default credential provider will invoke one provider at a time and only
* continue to the next if no credentials have been located. For example, if
* the process finds values defined via the `AWS_ACCESS_KEY_ID` and
* `AWS_SECRET_ACCESS_KEY` environment variables, the files at
* `~/.aws/credentials` and `~/.aws/config` will not be read, nor will any
* messages be sent to the Instance Metadata Service.
*
* @param init Configuration that is passed to each individual
* provider
*
* @see fromEnv The function used to source credentials from
* environment variables
* @see fromSSO The function used to source credentials from
* resolved SSO token cache
* @see fromTokenFile The function used to source credentials from
* token file
* @see fromIni The function used to source credentials from INI
* files
* @see fromProcess The function used to sources credentials from
* credential_process in INI files
* @see fromInstanceMetadata The function used to source credentials from the
* EC2 Instance Metadata Service
* @see fromContainerMetadata The function used to source credentials from the
* ECS Container Metadata Service
*/
export const defaultProvider = (
init: FromIniInit & RemoteProviderInit & FromProcessInit & FromSSOInit & FromTokenFileInit = {}
): CredentialProvider => {
const options = {
profile: process.env[ENV_PROFILE],
...init,
...(!init.loadedConfig && { loadedConfig: loadSharedConfigFiles(init) }),
};

const providerChain = chain(
...(options.profile ? [] : [fromEnv()]),
fromSSO(options),
fromIni(options),
fromProcess(options),
fromTokenFile(options),
remoteProvider(options),
async () => {
throw new CredentialsProviderError("Could not load credentials from any providers", false);
}
);

return memoize(
providerChain,
(credentials) => credentials.expiration !== undefined && credentials.expiration.getTime() - Date.now() < 300000,
(credentials) => credentials.expiration !== undefined
);
};
Loading

0 comments on commit 5f351cd

Please sign in to comment.