diff --git a/.changeset/strong-bulldogs-carry.md b/.changeset/strong-bulldogs-carry.md new file mode 100644 index 00000000000..52311c92f1e --- /dev/null +++ b/.changeset/strong-bulldogs-carry.md @@ -0,0 +1,5 @@ +--- +"@smithy/credential-provider-imds": minor +--- + +Add support for account ID in IMDS credentials diff --git a/packages/credential-provider-imds/package.json b/packages/credential-provider-imds/package.json index 996dd63b692..abbb5b5d4dd 100644 --- a/packages/credential-provider-imds/package.json +++ b/packages/credential-provider-imds/package.json @@ -15,7 +15,9 @@ "lint": "eslint -c ../../.eslintrc.js \"src/**/*.ts\"", "format": "prettier --config ../../prettier.config.js --ignore-path ../../.prettierignore --write \"**/*.{ts,md,json}\"", "test": "yarn g:vitest run", - "test:watch": "yarn g:vitest watch" + "test:e2e": "yarn g:vitest run -c vitest.config.e2e.ts --mode development", + "test:watch": "yarn g:vitest watch", + "test:e2e:watch": "yarn g:vitest watch -c vitest.config.e2e.ts" }, "keywords": [ "aws", diff --git a/packages/credential-provider-imds/src/fromContainerMetadata.ts b/packages/credential-provider-imds/src/fromContainerMetadata.ts index 2cfdff55568..70f3a36f511 100644 --- a/packages/credential-provider-imds/src/fromContainerMetadata.ts +++ b/packages/credential-provider-imds/src/fromContainerMetadata.ts @@ -5,7 +5,7 @@ import { parse } from "url"; import { httpRequest } from "./remoteProvider/httpRequest"; import { fromImdsCredentials, isImdsCredentials } from "./remoteProvider/ImdsCredentials"; -import { providerConfigFromInit, RemoteProviderInit } from "./remoteProvider/RemoteProviderInit"; +import { DEFAULT_MAX_RETRIES, DEFAULT_TIMEOUT, RemoteProviderInit } from "./remoteProvider/RemoteProviderInit"; import { retry } from "./remoteProvider/retry"; /** @@ -28,7 +28,7 @@ export const ENV_CMDS_AUTH_TOKEN = "AWS_CONTAINER_AUTHORIZATION_TOKEN"; * Container Metadata Service */ export const fromContainerMetadata = (init: RemoteProviderInit = {}): AwsCredentialIdentityProvider => { - const { timeout, maxRetries } = providerConfigFromInit(init); + const { timeout = DEFAULT_TIMEOUT, maxRetries = DEFAULT_MAX_RETRIES } = init; return () => retry(async () => { const requestOptions = await getCmdsUri({ logger: init.logger }); diff --git a/packages/credential-provider-imds/src/fromInstanceMetadata.e2e.spec.ts b/packages/credential-provider-imds/src/fromInstanceMetadata.e2e.spec.ts new file mode 100644 index 00000000000..3c11628bd2c --- /dev/null +++ b/packages/credential-provider-imds/src/fromInstanceMetadata.e2e.spec.ts @@ -0,0 +1,126 @@ +import { afterEach, beforeEach, describe, expect, test as it } from "vitest"; + +import { fromInstanceMetadata, getMetadataToken } from "./fromInstanceMetadata"; +import { getInstanceMetadataEndpoint } from "./utils/getInstanceMetadataEndpoint"; + +describe("fromInstanceMetadata (Live EC2 E2E Tests)", () => { + const originalEnv = { ...process.env }; + let imdsAvailable = false; + + beforeEach(async () => { + process.env = { ...originalEnv }; + + // Check IMDS availability + try { + const testProvider = fromInstanceMetadata({ timeout: 9000 }); + await testProvider(); + imdsAvailable = true; + } catch (err) { + imdsAvailable = false; + } + }); + + afterEach(() => { + process.env = { ...originalEnv }; + }); + + it("should fetch metadata token successfully", async (context) => { + if (!imdsAvailable) { + return context.skip(); + } + + const endpoint = await getInstanceMetadataEndpoint(); + const token = await getMetadataToken(endpoint); + expect(token).toBeDefined(); + expect(typeof token).toBe("string"); + expect(token.length).toBeGreaterThan(0); + }); + + it("retrieves credentials successfully", async (context) => { + if (!imdsAvailable) { + return context.skip(); + } + + const provider = fromInstanceMetadata(); + const credentials = await provider(); + + expect(credentials).toHaveProperty("accessKeyId"); + expect(credentials).toHaveProperty("secretAccessKey"); + expect(typeof credentials.accessKeyId).toBe("string"); + expect(typeof credentials.secretAccessKey).toBe("string"); + }); + + it("retrieves credentials with account ID on allowlisted instances", async (context) => { + if (!imdsAvailable) { + return context.skip(); + } + + const provider = fromInstanceMetadata(); + const credentials = await provider(); + + if (!credentials.accountId) { + context.skip(); + } + + expect(credentials.accountId).toBeDefined(); + expect(typeof credentials.accountId).toBe("string"); + }); + + it("IMDS access disabled via AWS_EC2_METADATA_DISABLED", async () => { + process.env.AWS_EC2_METADATA_DISABLED = "true"; + + const provider = fromInstanceMetadata(); + + await expect(provider()).rejects.toThrow("IMDS credential fetching is disabled"); + }); + + it("Empty configured profile name should throw error", async () => { + process.env.AWS_EC2_INSTANCE_PROFILE_NAME = " "; + + const provider = fromInstanceMetadata(); + + await expect(provider()).rejects.toThrow(); + }); + + it("Uses configured profile name from env", async (context) => { + if (!imdsAvailable) { + return context.skip(); + } + + const provider = fromInstanceMetadata(); + + try { + const credentials = await provider(); + expect(credentials).toHaveProperty("accessKeyId"); + } catch (error) { + expect(error).toBeDefined(); + } + }); + + it("Multiple calls return stable results", async (context) => { + if (!imdsAvailable) { + return context.skip(); + } + + const provider = fromInstanceMetadata(); + const creds1 = await provider(); + const creds2 = await provider(); + + expect(creds1.accessKeyId).toBeTruthy(); + expect(creds2.accessKeyId).toBeTruthy(); + expect(creds1.accessKeyId).toBe(creds2.accessKeyId); + }); + + /** + * The IMDS may respond too quickly to test this, + * even with 1ms timeout. + */ + it.skip("should timeout as expected when a request exceeds the specified duration", async (context) => { + if (!imdsAvailable) { + return context.skip(); + } + const provider = fromInstanceMetadata({ timeout: 1 }); + + await expect(provider()).rejects.toThrow(/timeout|timed out|TimeoutError/i); + }); +}); diff --git a/packages/credential-provider-imds/src/fromInstanceMetadata.spec.ts b/packages/credential-provider-imds/src/fromInstanceMetadata.spec.ts index d6a860d8493..e56b8aefed4 100644 --- a/packages/credential-provider-imds/src/fromInstanceMetadata.spec.ts +++ b/packages/credential-provider-imds/src/fromInstanceMetadata.spec.ts @@ -1,15 +1,21 @@ +import { loadConfig } from "@smithy/node-config-provider"; import { CredentialsProviderError } from "@smithy/property-provider"; import { afterEach, beforeEach, describe, expect, test as it, vi } from "vitest"; import { InstanceMetadataV1FallbackError } from "./error/InstanceMetadataV1FallbackError"; -import { fromInstanceMetadata } from "./fromInstanceMetadata"; +import { + fromInstanceMetadata, + getEc2InstanceProfileName, + getImdsProfile, + throwIfImdsTurnedOff, +} from "./fromInstanceMetadata"; import { httpRequest } from "./remoteProvider/httpRequest"; import { fromImdsCredentials, isImdsCredentials } from "./remoteProvider/ImdsCredentials"; -import { providerConfigFromInit } from "./remoteProvider/RemoteProviderInit"; import { retry } from "./remoteProvider/retry"; import { getInstanceMetadataEndpoint } from "./utils/getInstanceMetadataEndpoint"; import { staticStabilityProvider } from "./utils/staticStabilityProvider"; +vi.mock("@smithy/node-config-provider"); vi.mock("./remoteProvider/httpRequest"); vi.mock("./remoteProvider/ImdsCredentials"); vi.mock("./remoteProvider/retry"); @@ -36,7 +42,7 @@ describe("fromInstanceMetadata", () => { const mockProfileRequestOptions = { hostname, - path: "/latest/meta-data/iam/security-credentials/", + path: "/latest/meta-data/iam/security-credentials-extended/", timeout: mockTimeout, headers: { "x-aws-ec2-metadata-token": mockToken, @@ -49,6 +55,7 @@ describe("fromInstanceMetadata", () => { SecretAccessKey: "bar", Token: "baz", Expiration: ONE_HOUR_IN_FUTURE.toISOString(), + AccountId: "123456789012", }); const mockCreds = Object.freeze({ @@ -56,23 +63,69 @@ describe("fromInstanceMetadata", () => { secretAccessKey: mockImdsCreds.SecretAccessKey, sessionToken: mockImdsCreds.Token, expiration: new Date(mockImdsCreds.Expiration), + accountId: mockImdsCreds.AccountId, }); beforeEach(() => { vi.mocked(staticStabilityProvider).mockImplementation((input) => input); vi.mocked(getInstanceMetadataEndpoint).mockResolvedValue({ hostname } as any); + vi.mocked(loadConfig).mockReturnValue(() => Promise.resolve(false)); + vi.spyOn({ throwIfImdsTurnedOff }, "throwIfImdsTurnedOff").mockResolvedValue(undefined); (isImdsCredentials as unknown as any).mockReturnValue(true); - vi.mocked(providerConfigFromInit).mockReturnValue({ - timeout: mockTimeout, - maxRetries: mockMaxRetries, - }); }); afterEach(() => { vi.resetAllMocks(); }); - it("gets token and profile name to fetch credentials", async () => { + it("returns no credentials when AWS_EC2_METADATA_DISABLED=true", async () => { + vi.mocked(loadConfig).mockImplementation(({ environmentVariableSelector, configFileSelector }) => { + if (environmentVariableSelector && environmentVariableSelector({ AWS_EC2_METADATA_DISABLED: "true" })) { + return () => Promise.resolve(true); + } + if (configFileSelector && configFileSelector({ imds_disabled: "true" })) { + return () => Promise.resolve(true); + } + return () => Promise.resolve(false); + }); + vi.spyOn({ throwIfImdsTurnedOff }, "throwIfImdsTurnedOff").mockRejectedValueOnce( + new CredentialsProviderError("IMDS credential fetching is disabled") + ); + const provider = fromInstanceMetadata({}); + + await expect(provider()).rejects.toEqual(new CredentialsProviderError("IMDS credential fetching is disabled", {})); + expect(httpRequest).not.toHaveBeenCalled(); + }); + + it("returns valid credentials with account ID when ec2InstanceProfileName is provided", async () => { + const ec2InstanceProfileName = "my-profile-0002"; + + vi.mocked(httpRequest) + .mockImplementation(((...args: any[]) => { + console.log(...args); + return mockToken; + }) as any) + .mockResolvedValueOnce(mockToken as any) + .mockResolvedValueOnce(JSON.stringify(mockImdsCreds) as any) + .mockResolvedValueOnce(JSON.stringify(mockImdsCreds) as any); + + vi.mocked(retry).mockImplementation((fn: any) => fn()); + vi.mocked(fromImdsCredentials).mockReturnValue(mockCreds); + + const credentials = await fromInstanceMetadata({ ec2InstanceProfileName: ec2InstanceProfileName })(); + + expect(credentials).toEqual(mockCreds); + expect(credentials.accountId).toBe(mockCreds.accountId); + + expect(httpRequest).toHaveBeenNthCalledWith(1, mockTokenRequestOptions); + expect(httpRequest).toHaveBeenNthCalledWith(2, { + ...mockProfileRequestOptions, + path: `${mockProfileRequestOptions.path}${ec2InstanceProfileName}`, + }); + expect(httpRequest).toHaveBeenCalledTimes(2); + }); + + it("returns valid credentials with account ID when profile is discovered from IMDS", async () => { vi.mocked(httpRequest) .mockResolvedValueOnce(mockToken as any) .mockResolvedValueOnce(mockProfile as any) @@ -81,7 +134,13 @@ describe("fromInstanceMetadata", () => { vi.mocked(retry).mockImplementation((fn: any) => fn()); vi.mocked(fromImdsCredentials).mockReturnValue(mockCreds); - await expect(fromInstanceMetadata()()).resolves.toEqual(mockCreds); + const provider = fromInstanceMetadata({}); + + const credentials = await provider(); + + expect(credentials).toEqual(mockCreds); + expect(credentials.accountId).toBe(mockCreds.accountId); + expect(httpRequest).toHaveBeenCalledTimes(3); expect(httpRequest).toHaveBeenNthCalledWith(1, mockTokenRequestOptions); expect(httpRequest).toHaveBeenNthCalledWith(2, mockProfileRequestOptions); @@ -91,46 +150,39 @@ describe("fromInstanceMetadata", () => { }); }); - it("trims profile returned name from IMDS", async () => { + it("gets token and profile name to fetch credentials", async () => { vi.mocked(httpRequest) .mockResolvedValueOnce(mockToken as any) - .mockResolvedValueOnce((" " + mockProfile + " ") as any) + .mockResolvedValueOnce(mockProfile as any) .mockResolvedValueOnce(JSON.stringify(mockImdsCreds) as any); vi.mocked(retry).mockImplementation((fn: any) => fn()); vi.mocked(fromImdsCredentials).mockReturnValue(mockCreds); await expect(fromInstanceMetadata()()).resolves.toEqual(mockCreds); + expect(httpRequest).toHaveBeenCalledTimes(3); + expect(httpRequest).toHaveBeenNthCalledWith(1, mockTokenRequestOptions); + expect(httpRequest).toHaveBeenNthCalledWith(2, mockProfileRequestOptions); expect(httpRequest).toHaveBeenNthCalledWith(3, { ...mockProfileRequestOptions, path: `${mockProfileRequestOptions.path}${mockProfile}`, }); }); - it("passes {} to providerConfigFromInit if init not defined", async () => { - vi.mocked(retry).mockResolvedValueOnce(mockProfile).mockResolvedValueOnce(mockCreds); - - await expect(fromInstanceMetadata()()).resolves.toEqual(mockCreds); - expect(providerConfigFromInit).toHaveBeenCalledTimes(1); - expect(providerConfigFromInit).toHaveBeenCalledWith({}); - }); - - it("passes init to providerConfigFromInit", async () => { - vi.mocked(retry).mockResolvedValueOnce(mockProfile).mockResolvedValueOnce(mockCreds); - - const init = { maxRetries: 5, timeout: 1213 }; - await expect(fromInstanceMetadata(init)()).resolves.toEqual(mockCreds); - expect(providerConfigFromInit).toHaveBeenCalledTimes(1); - expect(providerConfigFromInit).toHaveBeenCalledWith(init); - }); + it("trims profile returned name from IMDS", async () => { + vi.mocked(httpRequest) + .mockResolvedValueOnce(mockToken as any) + .mockResolvedValueOnce((" " + mockProfile + " ") as any) + .mockResolvedValueOnce(JSON.stringify(mockImdsCreds) as any); - it("passes maxRetries returned from providerConfigFromInit to retry", async () => { - vi.mocked(retry).mockResolvedValueOnce(mockProfile).mockResolvedValueOnce(mockCreds); + vi.mocked(retry).mockImplementation((fn: any) => fn()); + vi.mocked(fromImdsCredentials).mockReturnValue(mockCreds); await expect(fromInstanceMetadata()()).resolves.toEqual(mockCreds); - expect(retry).toHaveBeenCalledTimes(2); - expect(vi.mocked(retry).mock.calls[0][1]).toBe(mockMaxRetries); - expect(vi.mocked(retry).mock.calls[1][1]).toBe(mockMaxRetries); + expect(httpRequest).toHaveBeenNthCalledWith(3, { + ...mockProfileRequestOptions, + path: `${mockProfileRequestOptions.path}${mockProfile}`, + }); }); it("throws CredentialsProviderError if credentials returned are incorrect", async () => { @@ -185,7 +237,7 @@ describe("fromInstanceMetadata", () => { .mockResolvedValueOnce("." as any); vi.mocked(retry).mockImplementation((fn: any) => fn()); - await expect(fromInstanceMetadata()()).rejects.toThrow("Unexpected token"); + await expect(fromInstanceMetadata()()).rejects.toThrow("Failed to parse JSON from instance metadata service."); expect(retry).toHaveBeenCalledTimes(2); expect(httpRequest).toHaveBeenCalledTimes(3); expect(fromImdsCredentials).not.toHaveBeenCalled(); @@ -213,6 +265,73 @@ describe("fromInstanceMetadata", () => { expect(vi.mocked(staticStabilityProvider)).toBeCalledTimes(1); }); + describe("getImdsProfile", () => { + beforeEach(() => { + vi.mocked(httpRequest).mockClear(); + vi.mocked(loadConfig).mockClear(); + vi.mocked(retry).mockImplementation((fn: any) => fn()); + }); + + it("uses ec2InstanceProfileName from init if provided", async () => { + const profileName = "profile-from-init"; + const options = { hostname }; + + vi.spyOn( + { getConfiguredProfileName: getEc2InstanceProfileName }, + "getConfiguredProfileName" + ).mockResolvedValueOnce(profileName); + + const credentials = await getImdsProfile(options, { + maxRetries: mockMaxRetries, + ec2InstanceProfileName: profileName, + }); + + expect(credentials).toBe(profileName); + expect(httpRequest).not.toHaveBeenCalled(); + }); + + it("uses environment variable if ec2InstanceProfileName not provided", async () => { + const envProfileName = "profile-from-env"; + const options = { hostname } as any; + + vi.mocked(loadConfig).mockReturnValue(() => Promise.resolve(envProfileName)); + + const credentials = await getImdsProfile(options, { maxRetries: mockMaxRetries }); + + expect(credentials).toBe(envProfileName); + expect(httpRequest).not.toHaveBeenCalled(); + }); + + it("uses profile from config file if present, otherwise falls back to IMDS (extended then legacy)", async () => { + const configProfileName = "profile-from-config"; + const legacyProfileName = "profile-from-legacy"; + const options = { hostname } as any; + + vi.mocked(loadConfig).mockReturnValue(() => Promise.resolve(configProfileName)); + + let credentials = await getImdsProfile(options, { maxRetries: mockMaxRetries }); + expect(credentials).toBe(configProfileName); + expect(httpRequest).not.toHaveBeenCalled(); + + vi.mocked(loadConfig).mockReturnValue(() => Promise.resolve(null)); + vi.mocked(httpRequest) + .mockRejectedValueOnce(Object.assign(new Error(), { statusCode: 404 })) + .mockResolvedValueOnce(legacyProfileName as any); + + credentials = await getImdsProfile(options, { maxRetries: mockMaxRetries }); + expect(credentials).toBe(legacyProfileName); + expect(httpRequest).toHaveBeenCalledTimes(2); + expect(httpRequest).toHaveBeenNthCalledWith(1, { + ...options, + path: "/latest/meta-data/iam/security-credentials-extended/", + }); + expect(httpRequest).toHaveBeenNthCalledWith(2, { + ...options, + path: "/latest/meta-data/iam/security-credentials/", + }); + }); + }); + describe("disables fetching of token", () => { beforeEach(() => { vi.mocked(retry).mockImplementation((fn: any) => fn()); diff --git a/packages/credential-provider-imds/src/fromInstanceMetadata.ts b/packages/credential-provider-imds/src/fromInstanceMetadata.ts index b1fca9f047e..15e8358ac93 100644 --- a/packages/credential-provider-imds/src/fromInstanceMetadata.ts +++ b/packages/credential-provider-imds/src/fromInstanceMetadata.ts @@ -1,28 +1,36 @@ import { loadConfig } from "@smithy/node-config-provider"; import { CredentialsProviderError } from "@smithy/property-provider"; -import { AwsCredentialIdentity, Provider } from "@smithy/types"; +import { AwsCredentialIdentity, Logger, Provider } from "@smithy/types"; import { RequestOptions } from "http"; import { InstanceMetadataV1FallbackError } from "./error/InstanceMetadataV1FallbackError"; import { httpRequest } from "./remoteProvider/httpRequest"; -import { fromImdsCredentials, isImdsCredentials } from "./remoteProvider/ImdsCredentials"; -import { providerConfigFromInit, RemoteProviderInit } from "./remoteProvider/RemoteProviderInit"; +import { fromImdsCredentials, ImdsCredentials, isImdsCredentials } from "./remoteProvider/ImdsCredentials"; +import { DEFAULT_MAX_RETRIES, DEFAULT_TIMEOUT, RemoteProviderInit } from "./remoteProvider/RemoteProviderInit"; import { retry } from "./remoteProvider/retry"; import { InstanceMetadataCredentials } from "./types"; import { getInstanceMetadataEndpoint } from "./utils/getInstanceMetadataEndpoint"; import { staticStabilityProvider } from "./utils/staticStabilityProvider"; -const IMDS_PATH = "/latest/meta-data/iam/security-credentials/"; -const IMDS_TOKEN_PATH = "/latest/api/token"; +const IMDS_LEGACY_PATH = "/latest/meta-data/iam/security-credentials/"; +const IMDS_EXTENDED_PATH = "/latest/meta-data/iam/security-credentials-extended/"; const AWS_EC2_METADATA_V1_DISABLED = "AWS_EC2_METADATA_V1_DISABLED"; const PROFILE_AWS_EC2_METADATA_V1_DISABLED = "ec2_metadata_v1_disabled"; +const IMDS_TOKEN_PATH = "/latest/api/token"; const X_AWS_EC2_METADATA_TOKEN = "x-aws-ec2-metadata-token"; +// Environment variables and config keys + +const ENV_IMDS_DISABLED = "AWS_EC2_METADATA_DISABLED"; +const CONFIG_IMDS_DISABLED = "disable_ec2_metadata"; +const ENV_PROFILE_NAME = "AWS_EC2_INSTANCE_PROFILE_NAME"; +const CONFIG_PROFILE_NAME = "ec2_instance_profile_name"; + /** - * @internal - * * Creates a credential provider that will source credentials from the EC2 * Instance Metadata Service + * + * @internal */ export const fromInstanceMetadata = (init: RemoteProviderInit = {}): Provider => staticStabilityProvider(getInstanceMetadataProvider(init), { logger: init.logger }); @@ -31,51 +39,56 @@ export const fromInstanceMetadata = (init: RemoteProviderInit = {}): Provider { + const { profile, logger, timeout = DEFAULT_TIMEOUT, maxRetries = DEFAULT_MAX_RETRIES, ec2MetadataV1Disabled } = init; + // when set to true, metadata service will not fetch token let disableFetchToken = false; - const { logger, profile } = init; - const { timeout, maxRetries } = providerConfigFromInit(init); - const getCredentials = async (maxRetries: number, options: RequestOptions) => { + const getCredentials = async (options: RequestOptions) => { const isImdsV1Fallback = disableFetchToken || options.headers?.[X_AWS_EC2_METADATA_TOKEN] == null; if (isImdsV1Fallback) { + await throwIfImdsTurnedOff({ profile, logger }); let fallbackBlockedFromProfile = false; let fallbackBlockedFromProcessEnv = false; - const configValue = await loadConfig( - { - environmentVariableSelector: (env) => { - const envValue = env[AWS_EC2_METADATA_V1_DISABLED]; - fallbackBlockedFromProcessEnv = !!envValue && envValue !== "false"; - if (envValue === undefined) { - throw new CredentialsProviderError( - `${AWS_EC2_METADATA_V1_DISABLED} not set in env, checking config file next.`, - { logger: init.logger } - ); - } - return fallbackBlockedFromProcessEnv; + const _ec2MetadataV1Disabled = + ec2MetadataV1Disabled ?? + (await loadConfig( + { + environmentVariableSelector: (env) => { + const envValue = env[AWS_EC2_METADATA_V1_DISABLED]; + if (envValue === undefined) { + return undefined; + } + fallbackBlockedFromProcessEnv = !!envValue && envValue !== "false"; + return fallbackBlockedFromProcessEnv; + }, + configFileSelector: (profile) => { + const profileValue = profile[PROFILE_AWS_EC2_METADATA_V1_DISABLED]; + if (profileValue === undefined) { + return undefined; + } + fallbackBlockedFromProfile = !!profileValue && profileValue !== "false"; + return fallbackBlockedFromProfile; + }, + default: false, }, - configFileSelector: (profile) => { - const profileValue = profile[PROFILE_AWS_EC2_METADATA_V1_DISABLED]; - fallbackBlockedFromProfile = !!profileValue && profileValue !== "false"; - return fallbackBlockedFromProfile; - }, - default: false, - }, - { - profile, - } - )(); + { + profile, + } + )()); - if (init.ec2MetadataV1Disabled || configValue) { + if (_ec2MetadataV1Disabled) { const causes: string[] = []; - if (init.ec2MetadataV1Disabled) + if (ec2MetadataV1Disabled) causes.push("credential provider initialization (runtime option ec2MetadataV1Disabled)"); - if (fallbackBlockedFromProfile) causes.push(`config file profile (${PROFILE_AWS_EC2_METADATA_V1_DISABLED})`); - if (fallbackBlockedFromProcessEnv) + if (fallbackBlockedFromProfile) { + causes.push(`config file profile (${PROFILE_AWS_EC2_METADATA_V1_DISABLED})`); + } + if (fallbackBlockedFromProcessEnv) { causes.push(`process environment variable (${AWS_EC2_METADATA_V1_DISABLED})`); - + } throw new InstanceMetadataV1FallbackError( `AWS EC2 Metadata v1 fallback has been blocked by AWS SDK configuration in the following: [${causes.join( ", " @@ -84,25 +97,12 @@ const getInstanceMetadataProvider = (init: RemoteProviderInit = {}) => { } } - const imdsProfile = ( - await retry(async () => { - let profile: string; - try { - profile = await getProfile(options); - } catch (err) { - if (err.statusCode === 401) { - disableFetchToken = false; - } - throw err; - } - return profile; - }, maxRetries) - ).trim(); + const imdsProfile = await getImdsProfile(options, init); return retry(async () => { let creds: AwsCredentialIdentity; try { - creds = await getCredentialsFromProfile(imdsProfile, options, init); + creds = await getCredentialsFromImdsProfile(imdsProfile, options, init); } catch (err) { if (err.statusCode === 401) { disableFetchToken = false; @@ -114,14 +114,15 @@ const getInstanceMetadataProvider = (init: RemoteProviderInit = {}) => { }; return async () => { + await throwIfImdsTurnedOff({ profile, logger }); const endpoint = await getInstanceMetadataEndpoint(); if (disableFetchToken) { logger?.debug("AWS SDK Instance Metadata", "using v1 fallback (no token fetch)"); - return getCredentials(maxRetries, { ...endpoint, timeout }); + return getCredentials({ ...endpoint, timeout }); } else { let token: string; try { - token = (await getMetadataToken({ ...endpoint, timeout })).toString(); + token = await getMetadataToken({ ...endpoint, timeout }); } catch (error) { if (error?.statusCode === 400) { throw Object.assign(error, { @@ -131,9 +132,9 @@ const getInstanceMetadataProvider = (init: RemoteProviderInit = {}) => { disableFetchToken = true; } logger?.debug("AWS SDK Instance Metadata", "using v1 fallback (initial)"); - return getCredentials(maxRetries, { ...endpoint, timeout }); + return getCredentials({ ...endpoint, timeout }); } - return getCredentials(maxRetries, { + return getCredentials({ ...endpoint, headers: { [X_AWS_EC2_METADATA_TOKEN]: token, @@ -144,33 +145,179 @@ const getInstanceMetadataProvider = (init: RemoteProviderInit = {}) => { }; }; -const getMetadataToken = async (options: RequestOptions) => - httpRequest({ - ...options, - path: IMDS_TOKEN_PATH, - method: "PUT", - headers: { - "x-aws-ec2-metadata-token-ttl-seconds": "21600", +/** + * @internal + * Gets IMDS profile with proper error handling and retries + */ +export const getImdsProfile = async (options: RequestOptions, init: RemoteProviderInit = {}): Promise => { + let apiVersion: "unknown" | "extended" | "legacy" = "unknown"; + let resolvedProfile: string | null = null; + + return retry(async () => { + // First check if a profile name is configured + const ec2InstanceProfileName = await getEc2InstanceProfileName(init); + if (ec2InstanceProfileName) { + return ec2InstanceProfileName; + } + // Try extended API first + try { + const response = await httpRequest({ ...options, path: IMDS_EXTENDED_PATH }); + resolvedProfile = response.toString().trim(); + if (apiVersion === "unknown") { + apiVersion = "extended"; + } + return resolvedProfile; + } catch (error) { + if (error?.statusCode === 404 && apiVersion === "unknown") { + apiVersion = "legacy"; + const response = await httpRequest({ ...options, path: IMDS_LEGACY_PATH }); + resolvedProfile = response.toString().trim(); + return resolvedProfile; + } else { + throw error; + } + } + }, init.maxRetries ?? DEFAULT_MAX_RETRIES); +}; + +/** + * @internal + */ +export const getMetadataToken = async (options: RequestOptions): Promise => + ( + await httpRequest({ + ...options, + path: IMDS_TOKEN_PATH, + method: "PUT", + headers: { + "x-aws-ec2-metadata-token-ttl-seconds": "21600", + }, + }) + ).toString(); + +/** + * Checks if IMDS credential fetching is disabled through configuration + * @internal + */ +export const throwIfImdsTurnedOff = async ({ + profile, + logger, +}: { + profile?: string; + logger?: Logger; +}): Promise => { + // Load configuration in priority order + const disableImds = await loadConfig( + { + // Check environment variable + environmentVariableSelector: (env) => { + const envValue = env[ENV_IMDS_DISABLED]; + if (envValue === undefined) { + return undefined; + } + return envValue === "true"; + }, + // Check config file + configFileSelector: (profile) => { + const profileValue = profile[CONFIG_IMDS_DISABLED]; + if (profileValue === undefined) { + return undefined; + } + return profileValue === "true"; + }, + default: false, }, - }); + { profile } + )(); + + // If IMDS is disabled, throw error + if (disableImds) { + throw new CredentialsProviderError("IMDS credential fetching is disabled", { logger }); + } +}; + +/** + * Gets configured profile name from various sources + * @internal + */ +export const getEc2InstanceProfileName = async (init: RemoteProviderInit): Promise => { + const ec2InstanceProfileName = + init.ec2InstanceProfileName ?? + (await loadConfig( + { + environmentVariableSelector: (env) => env[ENV_PROFILE_NAME], + configFileSelector: (profile) => profile[CONFIG_PROFILE_NAME], + default: undefined, + }, + { profile: init.profile } + )()); + + // Validate if name is provided but empty + if (typeof ec2InstanceProfileName === "string" && ec2InstanceProfileName.trim() === "") { + throw new CredentialsProviderError("EC2 instance profile name cannot be empty", { + logger: init?.logger, + }); + } + + return ec2InstanceProfileName; +}; + +/** + * Gets credentials from profile. + * + * @internal + */ +const getCredentialsFromImdsProfile = async ( + imdsProfile: string, + options: RequestOptions, + init: RemoteProviderInit +) => { + // Try extended API first + try { + return await getCredentialsFromPath(IMDS_EXTENDED_PATH + imdsProfile, options, init); + } catch (error) { + // If extended API returns 404, fall back to legacy API + if (error.statusCode === 404) { + try { + return await getCredentialsFromPath(IMDS_LEGACY_PATH + imdsProfile, options); + } catch (legacyError) { + if (legacyError.statusCode === 404 && init.ec2InstanceProfileName === undefined) { + // If legacy API also returns 404 and we're using a cached profile name, + // the profile might have changed - clear cache and retry + const newImdsProfile = await getImdsProfile(options, init); + return getCredentialsFromImdsProfile(newImdsProfile, options, init); + } + throw legacyError; + } + } + throw error; + } +}; -const getProfile = async (options: RequestOptions) => (await httpRequest({ ...options, path: IMDS_PATH })).toString(); +/** + * Gets credentials from specified IMDS path + * @internal + */ +async function getCredentialsFromPath(path: string, options: RequestOptions, init: RemoteProviderInit = {}) { + const response = await httpRequest({ + ...options, + path, + }); -const getCredentialsFromProfile = async (profile: string, options: RequestOptions, init: RemoteProviderInit) => { - const credentialsResponse = JSON.parse( - ( - await httpRequest({ - ...options, - path: IMDS_PATH + profile, - }) - ).toString() - ); + let credentialsResponse: ImdsCredentials | unknown; + try { + credentialsResponse = JSON.parse(response.toString()); + } catch (error) { + throw new CredentialsProviderError("Failed to parse JSON from instance metadata service.", { logger: init.logger }); + } + // Validate response if (!isImdsCredentials(credentialsResponse)) { throw new CredentialsProviderError("Invalid response received from instance metadata service.", { logger: init.logger, }); } + // Convert IMDS credentials format to standard format return fromImdsCredentials(credentialsResponse); -}; +} diff --git a/packages/credential-provider-imds/src/remoteProvider/RemoteProviderInit.spec.ts b/packages/credential-provider-imds/src/remoteProvider/RemoteProviderInit.spec.ts deleted file mode 100644 index 2877841029e..00000000000 --- a/packages/credential-provider-imds/src/remoteProvider/RemoteProviderInit.spec.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { describe, expect, test as it } from "vitest"; - -import { DEFAULT_MAX_RETRIES, DEFAULT_TIMEOUT, providerConfigFromInit } from "./RemoteProviderInit"; - -describe("providerConfigFromInit", () => { - it("should populate default values for retries and timeouts", () => { - expect(providerConfigFromInit({})).toEqual({ - timeout: DEFAULT_TIMEOUT, - maxRetries: DEFAULT_MAX_RETRIES, - }); - }); - - it("should pass through timeout and retries overrides", () => { - const timeout = 123456789; - const maxRetries = 987654321; - - expect(providerConfigFromInit({ timeout, maxRetries })).toEqual({ - timeout, - maxRetries, - }); - }); -}); diff --git a/packages/credential-provider-imds/src/remoteProvider/RemoteProviderInit.ts b/packages/credential-provider-imds/src/remoteProvider/RemoteProviderInit.ts index a6b5751cfa8..bd0270f1cae 100644 --- a/packages/credential-provider-imds/src/remoteProvider/RemoteProviderInit.ts +++ b/packages/credential-provider-imds/src/remoteProvider/RemoteProviderInit.ts @@ -5,12 +5,10 @@ import { Logger } from "@smithy/types"; */ export const DEFAULT_TIMEOUT = 1000; -// The default in AWS SDK for Python and CLI (botocore) is no retry or one attempt -// https://github.com/boto/botocore/blob/646c61a7065933e75bab545b785e6098bc94c081/botocore/utils.py#L273 /** * @internal */ -export const DEFAULT_MAX_RETRIES = 0; +export const DEFAULT_MAX_RETRIES = 3; /** * @public @@ -19,12 +17,12 @@ export interface RemoteProviderConfig { /** * The connection timeout (in milliseconds) */ - timeout: number; + timeout?: number; /** * The maximum number of times the HTTP connection should be retried */ - maxRetries: number; + maxRetries?: number; } /** @@ -36,16 +34,12 @@ export interface RemoteProviderInit extends Partial { * Only used in the IMDS credential provider. */ ec2MetadataV1Disabled?: boolean; + /** + * Explicitly specify EC2 instance profile name (IAM role) to use on the EC2 instance. + */ + ec2InstanceProfileName?: string; /** * AWS_PROFILE. */ profile?: string; } - -/** - * @internal - */ -export const providerConfigFromInit = ({ - maxRetries = DEFAULT_MAX_RETRIES, - timeout = DEFAULT_TIMEOUT, -}: RemoteProviderInit): RemoteProviderConfig => ({ maxRetries, timeout }); diff --git a/packages/credential-provider-imds/src/utils/staticStabilityProvider.ts b/packages/credential-provider-imds/src/utils/staticStabilityProvider.ts index fe9e9f6603d..80503436a86 100644 --- a/packages/credential-provider-imds/src/utils/staticStabilityProvider.ts +++ b/packages/credential-provider-imds/src/utils/staticStabilityProvider.ts @@ -11,7 +11,8 @@ import { getExtendedInstanceMetadataCredentials } from "./getExtendedInstanceMet * the recently expired credentials. This mitigates impact when clients using * refreshable credentials are unable to retrieve updates. * - * @param provider Credential provider + * @param provider - credential provider. + * @param options - logger container. * @returns A credential provider that supports static stability */ export const staticStabilityProvider = ( diff --git a/packages/credential-provider-imds/vitest.config.e2e.ts b/packages/credential-provider-imds/vitest.config.e2e.ts new file mode 100644 index 00000000000..92073c6cfcf --- /dev/null +++ b/packages/credential-provider-imds/vitest.config.e2e.ts @@ -0,0 +1,8 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + include: ["**/*.e2e.spec.ts"], + environment: "node", + }, +});