diff --git a/packages/credential-providers/README.md b/packages/credential-providers/README.md index 32cb9218a7d22..2561430137791 100644 --- a/packages/credential-providers/README.md +++ b/packages/credential-providers/README.md @@ -27,6 +27,8 @@ A collection of all credential providers. 1. [Sample Files](#sample-files-2) 1. [From Node.js default credentials provider chain](#fromnodeproviderchain) 1. [Creating a custom credentials chain](#createcredentialchain) +1. [Aws CliV2 Region resolution order](#resolveAwsCliV2Region) +1. [From AwsCliV2 compatible provider chain](#fromAwsCliV2CompatibleProviderChain) ## Terminology @@ -825,7 +827,7 @@ Successfully signed out of all SSO profiles. ### Sample files This credential provider is only applicable if the profile specified in shared configuration and -credentials files contain ALL of the following entries. +credentials files contain ALL the following entries. #### `~/.aws/credentials` @@ -948,6 +950,67 @@ new S3({ }); ``` +## `fromAwsCliV2CompatibleProviderChain()` + +- Not available in browsers & native apps. + +A credential provider that follows the same priority order as the AWS CLI v2 credential resolution. +This credential provider will attempt to find credentials from the following sources (listed in +order of precedence): + +- Inline code static credentials +- **when a profile is specified**: + - Same resolution as the [fromIni](#fromini) provider. +- **when a profile is not specified**: + - [Environment variables exposed via `process.env`](#fromenv) + - [Web identity token credentials](#fromtokenfile) + - [SSO credentials from token cache](#fromsso) + - [From Credential Process](#fromprocess) + - [From Instance and Container Metadata Service](#fromcontainermetadata-and-frominstancemetadata) + +```js +import { fromAwsCliV2CompatibleProviderChain, resolveAwsCliV2Region } from "@aws-sdk/credential-providers"; +import { S3Client } from "@aws-sdk/client-s3"; + +const s3Client = new S3Client({ + profile: "my-profile", + + // Implements AWS CLI v2-compatible credential resolution and proxy settings. + credentials: fromAwsCliV2CompatibleProviderChain({}), + + // Implements AWS CLI v2 region resolution logic. + region: resolveAwsCliV2Region({ + // (!) this duplication is required if not using the "default" profile. + profile: "my-profile", + defaultRegion: "us-east-1", // optional + }), +}); +``` + +### `resolveAwsCliV2Region()` + +- This is not a credential resolver. It is a region resolver included here for ease of access. +- Not available in browsers & native apps. + +The region is resolved using the following order of precedence (highest to lowest) in the cli v2. + +1. Environment Variables + - AWS_REGION + - AWS_DEFAULT_REGION +2. AWS Configuration Files + - Profile specific region from ~/.aws/config or ~/.aws/credentials + - Profile selection order: + 1. Explicitly provided profile + 2. AWS_PROFILE environment variable + 3. AWS_DEFAULT_PROFILE environment variable + 4. "default" profile +3. EC2/ECS Instance Metadata Service + - Region from instance identity document + - Automatically falls back if metadata service is unavailable +4. Default Region + - Uses provided default region if specified + - Returns undefined if no region can be determined + ## Add Custom Headers to STS assume-role calls You can specify the plugins--groups of middleware, to inject to the STS client. diff --git a/packages/credential-providers/package.json b/packages/credential-providers/package.json index ecc4e329b80fb..d94f11aafffac 100644 --- a/packages/credential-providers/package.json +++ b/packages/credential-providers/package.json @@ -40,11 +40,13 @@ "@aws-sdk/credential-provider-process": "*", "@aws-sdk/credential-provider-sso": "*", "@aws-sdk/credential-provider-web-identity": "*", + "@aws-sdk/ec2-metadata-service": "*", "@aws-sdk/nested-clients": "*", "@aws-sdk/types": "*", "@smithy/core": "^3.1.5", "@smithy/credential-provider-imds": "^4.0.1", "@smithy/property-provider": "^4.0.1", + "@smithy/shared-ini-file-loader": "^4.0.1", "@smithy/types": "^4.1.0", "tslib": "^2.6.2" }, diff --git a/packages/credential-providers/src/fromAwsCliV2CompatibleProviderChain.spec.ts b/packages/credential-providers/src/fromAwsCliV2CompatibleProviderChain.spec.ts new file mode 100644 index 0000000000000..e9cef5dacf98f --- /dev/null +++ b/packages/credential-providers/src/fromAwsCliV2CompatibleProviderChain.spec.ts @@ -0,0 +1,86 @@ +import type { Exact } from "@smithy/types"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { + AwsCliV2CompatibleProviderOptions, + fromAwsCliV2CompatibleProviderChain, +} from "./fromAwsCliV2CompatibleProviderChain"; + +// the options type should have no required fields. +type Assert = Exact>; +const typeAssertion: Assert = true as const; +void typeAssertion; + +describe("fromAwsCliV2CompatibleProviderChain", () => { + let mockFromIni: any; + let mockFromNodeProviderChain: any; + + const mockLogger = { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }; + + beforeEach(() => { + vi.clearAllMocks(); + vi.resetModules(); + + mockFromIni = vi.fn(() => + vi.fn(async () => ({ + accessKeyId: "PROFILE_ACCESS_KEY", + secretAccessKey: "PROFILE_SECRET_KEY", + })) + ); + vi.doMock("@aws-sdk/credential-provider-ini", () => ({ + fromIni: mockFromIni, + })); + + mockFromNodeProviderChain = vi.fn(() => + vi.fn(async () => ({ + accessKeyId: "AWS_SDK_CHAIN_AK", + secretAccessKey: "AWS_SDK_CHAIN_SK", + })) + ); + vi.doMock("@aws-sdk/credential-provider-node", () => ({ + defaultProvider: mockFromNodeProviderChain, + })); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("should use profile credentials when profile is specified", async () => { + const provider = fromAwsCliV2CompatibleProviderChain({ + profile: "test-profile", + logger: mockLogger, + }); + + const result = await provider(); + + expect(result).toEqual({ + accessKeyId: "PROFILE_ACCESS_KEY", + secretAccessKey: "PROFILE_SECRET_KEY", + }); + + expect(mockFromIni).toHaveBeenCalled(); + expect(mockLogger.debug).toHaveBeenCalledWith( + `@aws-sdk/credential-providers - fromAwsCliV2CompatibleProviderChain - Using fromIni with profile: test-profile` + ); + }); + + it.only("should fall back to fromNodeProviderChain when no profile is specified", async () => { + const provider = fromAwsCliV2CompatibleProviderChain({ + logger: console, + }); + + const result = await provider(); + + expect(result).toEqual({ + accessKeyId: "AWS_SDK_CHAIN_AK", + secretAccessKey: "AWS_SDK_CHAIN_SK", + }); + expect(mockFromNodeProviderChain).toHaveBeenCalled(); + }); +}); diff --git a/packages/credential-providers/src/fromAwsCliV2CompatibleProviderChain.ts b/packages/credential-providers/src/fromAwsCliV2CompatibleProviderChain.ts new file mode 100644 index 0000000000000..9871159d617de --- /dev/null +++ b/packages/credential-providers/src/fromAwsCliV2CompatibleProviderChain.ts @@ -0,0 +1,66 @@ +import type { DefaultProviderInit } from "@aws-sdk/credential-provider-node"; +import type { RuntimeConfigAwsCredentialIdentityProvider } from "@aws-sdk/types"; +import type { AwsCredentialIdentity } from "@smithy/types"; + +/** + * @public + */ +export type AwsCliV2CompatibleProviderOptions = Partial & DefaultProviderInit; + +/** + * Creates an alternate form of the AWS SDK for JavaScript's fromNodeProviderChain. + * This differs in ways that makes it behave more like the AWS CLI v2: + * 1. It allows inline static credentials. + * 2. It checks AWS_DEFAULT_PROFILE in addition to AWS_PROFILE. + * 3. It prioritizes fromIni if a profile is set. + * Otherwise, it behaves as fromNodeProviderChain. + * + * @public + * + * @param _init - Configuration options for the provider chain. + * @returns An AWS credential provider. + */ +export const fromAwsCliV2CompatibleProviderChain = + (_init: AwsCliV2CompatibleProviderOptions = {}): RuntimeConfigAwsCredentialIdentityProvider => + async ({ callerClientConfig } = {}): Promise => { + // Merge init with caller's client config (profile/region). + const init: AwsCliV2CompatibleProviderOptions = { + ..._init, + profile: + _init.profile ?? callerClientConfig?.profile ?? process.env.AWS_PROFILE ?? process.env.AWS_DEFAULT_PROFILE, + logger: _init.logger ?? callerClientConfig?.logger, + }; + const { profile, accessKeyId, secretAccessKey, sessionToken, expiration, accountId } = init; + + const debug = init.logger?.debug ?? (() => {}); + + debug("@aws-sdk/credential-providers - fromAwsCliV2CompatibleProviderChain - init"); + + // 1. If credentials are explicitly provided, return them. + if (accessKeyId && secretAccessKey) { + debug("@aws-sdk/credential-providers - fromAwsCliV2CompatibleProviderChain - static credentials from init"); + return { + accessKeyId, + secretAccessKey, + ...(sessionToken && { sessionToken }), + ...(expiration && { expiration }), + ...(accountId && { accountId }), + } as AwsCredentialIdentity; + } + + // 2. If a profile is explicitly passed, use `fromIni`. + if (profile) { + debug( + `@aws-sdk/credential-providers - fromAwsCliV2CompatibleProviderChain - Using fromIni with profile: ${profile}` + ); + const { fromIni } = await import("@aws-sdk/credential-provider-ini"); + return fromIni(init)({ callerClientConfig }); + } + + // 3. Defer to AWS SDK credential chain. + debug("@aws-sdk/credential-providers - fromAwsCliV2CompatibleProviderChain - defer to fromNodeProviderChain"); + const { defaultProvider: fromNodeProviderChain } = await import("@aws-sdk/credential-provider-node"); + return fromNodeProviderChain(init)({ + // todo: fromNodeProviderChain should be changed to RuntimeConfigAwsCredentialIdentityProvider. + }); + }; diff --git a/packages/credential-providers/src/index.ts b/packages/credential-providers/src/index.ts index e23687adcc6f2..63f5f263044eb 100644 --- a/packages/credential-providers/src/index.ts +++ b/packages/credential-providers/src/index.ts @@ -1,4 +1,5 @@ export * from "./createCredentialChain"; +export * from "./fromAwsCliV2CompatibleProviderChain"; export * from "./fromCognitoIdentity"; export * from "./fromCognitoIdentityPool"; export * from "./fromContainerMetadata"; @@ -12,3 +13,4 @@ export * from "./fromSSO"; export * from "./fromTemporaryCredentials"; export * from "./fromTokenFile"; export * from "./fromWebToken"; +export { resolveAwsCliV2Region } from "./resolveAwsCliV2Region"; diff --git a/packages/credential-providers/src/resolveAwsCliV2Region.spec.ts b/packages/credential-providers/src/resolveAwsCliV2Region.spec.ts new file mode 100644 index 0000000000000..65bd942b15f02 --- /dev/null +++ b/packages/credential-providers/src/resolveAwsCliV2Region.spec.ts @@ -0,0 +1,155 @@ +import { MetadataService } from "@aws-sdk/ec2-metadata-service"; +import { loadSharedConfigFiles } from "@smithy/shared-ini-file-loader"; +import { afterEach, beforeEach, describe, expect, test as it, vi } from "vitest"; + +import { getRegionFromIni, regionFromMetadataService, resolveAwsCliV2Region } from "./resolveAwsCliV2Region"; + +vi.mock("@aws-sdk/ec2-metadata-service"); +vi.mock("@smithy/shared-ini-file-loader", () => ({ + loadSharedConfigFiles: vi.fn(), +})); + +describe("AWS Region Resolution", () => { + // Store original environment variables to restore them later + const originalEnv = { ...process.env }; + + beforeEach(() => { + // Reset environment variables before each test + process.env = { ...originalEnv }; + // Clear all mock data before each test + vi.clearAllMocks(); + }); + + afterEach(() => { + // Restore environment variables after each test + process.env = { ...originalEnv }; + }); + + describe("resolveAwsCliV2Region", () => { + it("should use AWS_REGION from environment variables", async () => { + process.env.AWS_REGION = "us-west-2"; + const region = await resolveAwsCliV2Region({})(); + expect(region).toBe("us-west-2"); + }); + + it("should fall back to AWS_DEFAULT_REGION if AWS_REGION is empty", async () => { + process.env.AWS_REGION = ""; + process.env.AWS_DEFAULT_REGION = "eu-west-1"; + const region = await resolveAwsCliV2Region({})(); + expect(region).toBe("eu-west-1"); + }); + + it("should use defaultRegion when no other source is available", async () => { + delete process.env.AWS_REGION; + delete process.env.AWS_DEFAULT_REGION; + // Mock loadSharedConfigFiles to return empty configuration + vi.mocked(loadSharedConfigFiles).mockResolvedValue({ + configFile: {}, + credentialsFile: {}, + }); + const consoleSpy = vi.spyOn(console, "warn"); + const region = await resolveAwsCliV2Region({ defaultRegion: "ap-southeast-1" })(); + expect(region).toBe("ap-southeast-1"); + expect(consoleSpy).toHaveBeenCalled(); + }); + + it("should return undefined when no region is available and no default region is provided", async () => { + delete process.env.AWS_REGION; + delete process.env.AWS_DEFAULT_REGION; + // Mock loadSharedConfigFiles to return empty configuration + vi.mocked(loadSharedConfigFiles).mockResolvedValue({ + configFile: {}, + credentialsFile: {}, + }); + const consoleSpy = vi.spyOn(console, "warn"); + const region = await resolveAwsCliV2Region({})(); + expect(region).toBeUndefined(); + expect(consoleSpy).toHaveBeenCalled(); + }); + + it("should use specified profile", async () => { + delete process.env.AWS_REGION; + delete process.env.AWS_DEFAULT_REGION; + vi.mocked(loadSharedConfigFiles).mockResolvedValue({ + configFile: { + "custom-profile": { + region: "us-east-1", + }, + }, + credentialsFile: {}, + }); + + const region = await resolveAwsCliV2Region({ profile: "custom-profile" })(); + expect(region).toBe("us-east-1"); + }); + }); + + describe("getRegionFromIni", () => { + it("should get region from config file", async () => { + vi.mocked(loadSharedConfigFiles).mockResolvedValue({ + configFile: { + default: { + region: "us-east-2", + }, + }, + credentialsFile: {}, + }); + + const region = await getRegionFromIni("default"); + expect(region).toBe("us-east-2"); + }); + + it("should fall back to credentials file", async () => { + vi.mocked(loadSharedConfigFiles).mockResolvedValue({ + configFile: {}, + credentialsFile: { + default: { + region: "eu-central-1", + }, + }, + }); + + const region = await getRegionFromIni("default"); + expect(region).toBe("eu-central-1"); + }); + + it("should return undefined when no region is found", async () => { + vi.mocked(loadSharedConfigFiles).mockResolvedValue({ + configFile: {}, + credentialsFile: {}, + }); + + const region = await getRegionFromIni("default"); + expect(region).toBeUndefined(); + }); + }); + + describe("regionFromMetadataService", () => { + it("should get region from EC2 metadata service", async () => { + vi.mocked(MetadataService).mockImplementation( + () => + ({ + request: vi.fn().mockResolvedValue(JSON.stringify({ region: "us-west-1" })), + } as any) + ); + + const region = await regionFromMetadataService(); + expect(region).toBe("us-west-1"); + }); + + it("should handle metadata service errors", async () => { + vi.mocked(MetadataService).mockImplementation( + () => + ({ + request: vi.fn().mockRejectedValue(new Error("IMDS not available")), + } as any) + ); + + const consoleSpy = vi.spyOn(console, "warn"); + const region = await regionFromMetadataService(); + + expect(region).toBeUndefined(); + expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining("IMDS not available")); + }); + }); +}); diff --git a/packages/credential-providers/src/resolveAwsCliV2Region.ts b/packages/credential-providers/src/resolveAwsCliV2Region.ts new file mode 100644 index 0000000000000..54ad7af5452a5 --- /dev/null +++ b/packages/credential-providers/src/resolveAwsCliV2Region.ts @@ -0,0 +1,70 @@ +import { loadSharedConfigFiles } from "@smithy/shared-ini-file-loader"; +import type { Provider } from "@smithy/types"; + +/** + * @public + */ +export interface ResolveRegionOptions { + defaultRegion?: string; + profile?: string; +} + +/** + * Resolves the AWS region following AWS CLI V2 precedence order. + * + * Please note that unlike with credential providers, if this region + * provider is used in the context of a client, the client's profile + * will not be passed to this provider at runtime. + * + * @public + */ +export const resolveAwsCliV2Region = ({ + defaultRegion, + profile, +}: ResolveRegionOptions): Provider => { + return async () => { + const resolvedProfile = profile ?? process.env.AWS_PROFILE ?? process.env.AWS_DEFAULT_PROFILE ?? "default"; + + const region = + process.env.AWS_REGION || + process.env.AWS_DEFAULT_REGION || + (await getRegionFromIni(resolvedProfile)) || + (await regionFromMetadataService()); + + if (!region) { + const usedProfile = resolvedProfile ? ` (profile: "${resolvedProfile}")` : ""; + console.warn( + `@aws-sdk/credential-providers::resolveAwsCliV2Region - ` + + `Unable to determine AWS region from environment or AWS profile=${usedProfile}. Returning ${defaultRegion}.` + ); + return defaultRegion ?? undefined; + } + + return region; + }; +}; + +/** + * Fetches the region from the AWS shared config files. + * @private + */ +export async function getRegionFromIni(profile: string): Promise { + const sharedFiles = await loadSharedConfigFiles({ ignoreCache: true }); + return sharedFiles.configFile?.[profile]?.region || sharedFiles.credentialsFile?.[profile]?.region; +} + +/** + * Retrieves the AWS region from the EC2 Instance Metadata Service (IMDS). + * @private + */ +export async function regionFromMetadataService(): Promise { + try { + const { MetadataService } = await import("@aws-sdk/ec2-metadata-service"); + const metadataService = new MetadataService(); + const document = await metadataService.request("/latest/dynamic/instance-identity/document", {}); + return JSON.parse(document).region; + } catch (e) { + console.warn(`Unable to fetch region from EC2 Instance Metadata Service. Error: ${e.message}`); + return undefined; + } +} diff --git a/packages/ec2-metadata-service/package.json b/packages/ec2-metadata-service/package.json index 32a821fb271ec..133ad885780a6 100644 --- a/packages/ec2-metadata-service/package.json +++ b/packages/ec2-metadata-service/package.json @@ -32,7 +32,6 @@ "tslib": "^2.6.2" }, "devDependencies": { - "@aws-sdk/credential-providers": "*", "@tsconfig/recommended": "1.0.1", "@types/node": "^18.19.69", "concurrently": "7.0.0", diff --git a/yarn.lock b/yarn.lock index 2d62d1fc8a5c7..4ee5027a94258 100644 --- a/yarn.lock +++ b/yarn.lock @@ -22634,11 +22634,13 @@ __metadata: "@aws-sdk/credential-provider-process": "npm:*" "@aws-sdk/credential-provider-sso": "npm:*" "@aws-sdk/credential-provider-web-identity": "npm:*" + "@aws-sdk/ec2-metadata-service": "npm:*" "@aws-sdk/nested-clients": "npm:*" "@aws-sdk/types": "npm:*" "@smithy/core": "npm:^3.1.5" "@smithy/credential-provider-imds": "npm:^4.0.1" "@smithy/property-provider": "npm:^4.0.1" + "@smithy/shared-ini-file-loader": "npm:^4.0.1" "@smithy/types": "npm:^4.1.0" "@tsconfig/recommended": "npm:1.0.1" "@types/node": "npm:^18.19.69" @@ -22689,11 +22691,10 @@ __metadata: languageName: unknown linkType: soft -"@aws-sdk/ec2-metadata-service@workspace:packages/ec2-metadata-service": +"@aws-sdk/ec2-metadata-service@npm:*, @aws-sdk/ec2-metadata-service@workspace:packages/ec2-metadata-service": version: 0.0.0-use.local resolution: "@aws-sdk/ec2-metadata-service@workspace:packages/ec2-metadata-service" dependencies: - "@aws-sdk/credential-providers": "npm:*" "@aws-sdk/types": "npm:*" "@smithy/node-config-provider": "npm:^4.0.1" "@smithy/node-http-handler": "npm:^4.0.3"