diff --git a/packages/credential-provider-ini/src/resolveAssumeRoleCredentials.spec.ts b/packages/credential-provider-ini/src/resolveAssumeRoleCredentials.spec.ts index 52adc6ebb908..a0e8c8ac0222 100644 --- a/packages/credential-provider-ini/src/resolveAssumeRoleCredentials.spec.ts +++ b/packages/credential-provider-ini/src/resolveAssumeRoleCredentials.spec.ts @@ -169,9 +169,15 @@ describe(resolveAssumeRoleCredentials.name, () => { const receivedCreds = await resolveAssumeRoleCredentials(mockProfileCurrent, mockProfilesWithSource, mockOptions); expect(receivedCreds).toStrictEqual(mockCreds); - expect(resolveProfileData).toHaveBeenCalledWith(mockProfileName, mockProfilesWithSource, mockOptions, { - mockProfileName: true, - }); + expect(resolveProfileData).toHaveBeenCalledWith( + mockProfileName, + mockProfilesWithSource, + mockOptions, + { + mockProfileName: true, + }, + false + ); expect(resolveCredentialSource).not.toHaveBeenCalled(); expect(mockOptions.roleAssumer).toHaveBeenCalledWith(mockSourceCredsFromProfile, { RoleArn: mockRoleAssumeParams.role_arn, diff --git a/packages/credential-provider-ini/src/resolveAssumeRoleCredentials.ts b/packages/credential-provider-ini/src/resolveAssumeRoleCredentials.ts index 0357c9564d8a..8cf9ac29c5ae 100644 --- a/packages/credential-provider-ini/src/resolveAssumeRoleCredentials.ts +++ b/packages/credential-provider-ini/src/resolveAssumeRoleCredentials.ts @@ -1,6 +1,6 @@ import { CredentialsProviderError } from "@smithy/property-provider"; import { getProfileName } from "@smithy/shared-ini-file-loader"; -import { AwsCredentialIdentity, Logger, ParsedIniData, Profile } from "@smithy/types"; +import { AwsCredentialIdentity, IniSection, Logger, ParsedIniData, Profile } from "@smithy/types"; import { FromIniInit } from "./fromIni"; import { resolveCredentialSource } from "./resolveCredentialSource"; @@ -140,43 +140,62 @@ export const resolveAssumeRoleCredentials = async ( const sourceCredsProvider: Promise = source_profile ? resolveProfileData( source_profile, - { - ...profiles, - [source_profile]: { - ...profiles[source_profile], - // This assigns the role_arn of the "root" profile - // to the credential_source profile so this recursive call knows - // what role to assume. - role_arn: data.role_arn ?? profiles[source_profile].role_arn, - }, - }, + profiles, options, { ...visitedProfiles, [source_profile]: true, - } + }, + isCredentialSourceWithoutRoleArn(profiles[source_profile!] ?? {}) ) : (await resolveCredentialSource(data.credential_source!, profileName, options.logger)(options))(); - const params: AssumeRoleParams = { - RoleArn: data.role_arn!, - RoleSessionName: data.role_session_name || `aws-sdk-js-${Date.now()}`, - ExternalId: data.external_id, - DurationSeconds: parseInt(data.duration_seconds || "3600", 10), - }; - - const { mfa_serial } = data; - if (mfa_serial) { - if (!options.mfaCodeProvider) { - throw new CredentialsProviderError( - `Profile ${profileName} requires multi-factor authentication, but no MFA code callback was provided.`, - { logger: options.logger, tryNextLink: false } - ); + if (isCredentialSourceWithoutRoleArn(data)) { + /** + * This control-flow branch is accessed when in a chained source_profile + * scenario, and the last step of the chain is a credential_source + * without its own role_arn. In this case, we return the credentials + * of the credential_source so that the previous recursive layer + * can use its role_arn instead of redundantly needing another role_arn at + * this final layer. + */ + return sourceCredsProvider; + } else { + const params: AssumeRoleParams = { + RoleArn: data.role_arn!, + RoleSessionName: data.role_session_name || `aws-sdk-js-${Date.now()}`, + ExternalId: data.external_id, + DurationSeconds: parseInt(data.duration_seconds || "3600", 10), + }; + + const { mfa_serial } = data; + if (mfa_serial) { + if (!options.mfaCodeProvider) { + throw new CredentialsProviderError( + `Profile ${profileName} requires multi-factor authentication, but no MFA code callback was provided.`, + { logger: options.logger, tryNextLink: false } + ); + } + params.SerialNumber = mfa_serial; + params.TokenCode = await options.mfaCodeProvider(mfa_serial); } - params.SerialNumber = mfa_serial; - params.TokenCode = await options.mfaCodeProvider(mfa_serial); + + const sourceCreds = await sourceCredsProvider; + return options.roleAssumer!(sourceCreds, params); } +}; - const sourceCreds = await sourceCredsProvider; - return options.roleAssumer!(sourceCreds, params); +/** + * @internal + * + * Returns true when the ini section in question, typically a profile, + * has a credential_source but not a role_arn. + * + * Previously, a role_arn was a required sibling element to credential_source. + * However, this would require a role_arn+source_profile pointed to a + * credential_source to have a second role_arn, resulting in at least two + * calls to assume-role. + */ +const isCredentialSourceWithoutRoleArn = (section: IniSection): boolean => { + return !section.role_arn && !!section.credential_source; }; diff --git a/packages/credential-provider-ini/src/resolveProfileData.ts b/packages/credential-provider-ini/src/resolveProfileData.ts index ea35a74ffe2f..42a4b30115dc 100644 --- a/packages/credential-provider-ini/src/resolveProfileData.ts +++ b/packages/credential-provider-ini/src/resolveProfileData.ts @@ -15,7 +15,15 @@ export const resolveProfileData = async ( profileName: string, profiles: ParsedIniData, options: FromIniInit, - visitedProfiles: Record = {} + visitedProfiles: Record = {}, + /** + * This override comes from recursive calls only. + * It is used to flag a recursive profile section + * that does not have a role_arn, e.g. a credential_source + * with no role_arn, as part of a larger recursive assume-role + * call stack, and to re-enter the assume-role resolver function. + */ + isAssumeRoleRecursiveCall = false ): Promise => { const data = profiles[profileName]; @@ -28,7 +36,7 @@ export const resolveProfileData = async ( // If this is the first profile visited, role assumption keys should be // given precedence over static credentials. - if (isAssumeRoleProfile(data, { profile: profileName, logger: options.logger })) { + if (isAssumeRoleRecursiveCall || isAssumeRoleProfile(data, { profile: profileName, logger: options.logger })) { return resolveAssumeRoleCredentials(profileName, profiles, options, visitedProfiles); } diff --git a/packages/credential-provider-node/src/credential-provider-node.integ.spec.ts b/packages/credential-provider-node/src/credential-provider-node.integ.spec.ts index ac6485335e59..12d9e1986701 100644 --- a/packages/credential-provider-node/src/credential-provider-node.integ.spec.ts +++ b/packages/credential-provider-node/src/credential-provider-node.integ.spec.ts @@ -72,6 +72,7 @@ jest.mock("@aws-sdk/client-sso", () => { // This var must be hoisted. // eslint-disable-next-line no-var var stsSpy: jest.Spied | any | undefined = undefined; +const assumeRoleArns: string[] = []; jest.mock("@aws-sdk/client-sts", () => { const actual = jest.requireActual("@aws-sdk/client-sts"); @@ -80,6 +81,7 @@ jest.mock("@aws-sdk/client-sts", () => { stsSpy = jest.spyOn(actual.STSClient.prototype, "send").mockImplementation(async function (this: any, command: any) { if (command.constructor.name === "AssumeRoleCommand") { + assumeRoleArns.push(command.input.RoleArn); return { Credentials: { AccessKeyId: "STS_AR_ACCESS_KEY_ID", @@ -91,6 +93,7 @@ jest.mock("@aws-sdk/client-sts", () => { }; } if (command.constructor.name === "AssumeRoleWithWebIdentityCommand") { + assumeRoleArns.push(command.input.RoleArn); return { Credentials: { AccessKeyId: "STS_ARWI_ACCESS_KEY_ID", @@ -177,6 +180,22 @@ describe("credential-provider-node integration test", () => { let sts: STS = null as any; let processSnapshot: typeof process.env = null as any; + const sink = { + data: [] as string[], + debug(log: string) { + this.data.push(log); + }, + info(log: string) { + this.data.push(log); + }, + warn(log: string) { + this.data.push(log); + }, + error(log: string) { + this.data.push(log); + }, + }; + const RESERVED_ENVIRONMENT_VARIABLES = { AWS_DEFAULT_REGION: 1, AWS_REGION: 1, @@ -257,6 +276,8 @@ describe("credential-provider-node integration test", () => { output: "json", }, }; + assumeRoleArns.length = 0; + sink.data.length = 0; }); afterAll(async () => { @@ -511,7 +532,7 @@ describe("credential-provider-node integration test", () => { }); }); - it("should be able to combine a source_profile having credential_source with an origin profile having role_arn and source_profile", async () => { + it("should be able to combine a source_profile having only credential_source with an origin profile having role_arn and source_profile", async () => { process.env.AWS_CONTAINER_CREDENTIALS_FULL_URI = "http://169.254.170.23"; process.env.AWS_CONTAINER_AUTHORIZATION_TOKEN = "container-authorization"; iniProfileData.default.source_profile = "credential_source_profile"; @@ -529,6 +550,138 @@ describe("credential-provider-node integration test", () => { clientConfig: { region: "us-west-2", }, + logger: sink, + }), + }); + await sts.getCallerIdentity({}); + const credentials = await sts.config.credentials(); + expect(credentials).toEqual({ + accessKeyId: "STS_AR_ACCESS_KEY_ID", + secretAccessKey: "STS_AR_SECRET_ACCESS_KEY", + sessionToken: "STS_AR_SESSION_TOKEN", + expiration: new Date("3000-01-01T00:00:00.000Z"), + credentialScope: "us-stsar-1__us-west-2", + }); + expect(spy).toHaveBeenCalledWith( + expect.objectContaining({ + awsContainerCredentialsFullUri: process.env.AWS_CONTAINER_CREDENTIALS_FULL_URI, + awsContainerAuthorizationToken: process.env.AWS_CONTAINER_AUTHORIZATION_TOKEN, + }) + ); + expect(assumeRoleArns).toEqual(["ROLE_ARN"]); + spy.mockClear(); + }); + + it("should be able to combine a source_profile having web_identity_token_file and role_arn with an origin profile having role_arn and source_profile", async () => { + iniProfileData.default.source_profile = "credential_source_profile"; + iniProfileData.default.role_arn = "ROLE_ARN_2"; + + iniProfileData.credential_source_profile = { + web_identity_token_file: "token-filepath", + role_arn: "ROLE_ARN_1", + }; + + sts = new STS({ + region: "us-west-2", + requestHandler: mockRequestHandler, + credentials: defaultProvider({ + awsContainerCredentialsFullUri: process.env.AWS_CONTAINER_CREDENTIALS_FULL_URI, + awsContainerAuthorizationToken: process.env.AWS_CONTAINER_AUTHORIZATION_TOKEN, + clientConfig: { + region: "us-west-2", + }, + logger: sink, + }), + }); + await sts.getCallerIdentity({}); + const credentials = await sts.config.credentials(); + expect(credentials).toEqual({ + accessKeyId: "STS_AR_ACCESS_KEY_ID", + secretAccessKey: "STS_AR_SECRET_ACCESS_KEY", + sessionToken: "STS_AR_SESSION_TOKEN", + expiration: new Date("3000-01-01T00:00:00.000Z"), + credentialScope: "us-stsar-1__us-west-2", + }); + expect(assumeRoleArns).toEqual(["ROLE_ARN_1", "ROLE_ARN_2"]); + }); + + it("should complete chained role_arn credentials", async () => { + process.env.AWS_CONTAINER_CREDENTIALS_FULL_URI = "http://169.254.170.23"; + process.env.AWS_CONTAINER_AUTHORIZATION_TOKEN = "container-authorization"; + + iniProfileData.default.source_profile = "credential_source_profile_1"; + iniProfileData.default.role_arn = "ROLE_ARN_3"; + + iniProfileData.credential_source_profile_1 = { + source_profile: "credential_source_profile_2", + role_arn: "ROLE_ARN_2", + }; + + iniProfileData.credential_source_profile_2 = { + credential_source: "EcsContainer", + role_arn: "ROLE_ARN_1", + }; + + const spy = jest.spyOn(credentialProviderHttp, "fromHttp"); + sts = new STS({ + region: "us-west-2", + requestHandler: mockRequestHandler, + credentials: defaultProvider({ + awsContainerCredentialsFullUri: process.env.AWS_CONTAINER_CREDENTIALS_FULL_URI, + awsContainerAuthorizationToken: process.env.AWS_CONTAINER_AUTHORIZATION_TOKEN, + clientConfig: { + region: "us-west-2", + }, + logger: sink, + }), + }); + await sts.getCallerIdentity({}); + const credentials = await sts.config.credentials(); + expect(credentials).toEqual({ + accessKeyId: "STS_AR_ACCESS_KEY_ID", + secretAccessKey: "STS_AR_SECRET_ACCESS_KEY", + sessionToken: "STS_AR_SESSION_TOKEN", + expiration: new Date("3000-01-01T00:00:00.000Z"), + credentialScope: "us-stsar-1__us-west-2", + }); + expect(spy).toHaveBeenCalledWith( + expect.objectContaining({ + awsContainerCredentialsFullUri: process.env.AWS_CONTAINER_CREDENTIALS_FULL_URI, + awsContainerAuthorizationToken: process.env.AWS_CONTAINER_AUTHORIZATION_TOKEN, + }) + ); + expect(assumeRoleArns).toEqual(["ROLE_ARN_1", "ROLE_ARN_2", "ROLE_ARN_3"]); + spy.mockClear(); + }); + + it("should complete chained role_arn credentials with optional role_arn in credential_source step", async () => { + process.env.AWS_CONTAINER_CREDENTIALS_FULL_URI = "http://169.254.170.23"; + process.env.AWS_CONTAINER_AUTHORIZATION_TOKEN = "container-authorization"; + + iniProfileData.default.source_profile = "credential_source_profile_1"; + iniProfileData.default.role_arn = "ROLE_ARN_3"; + + iniProfileData.credential_source_profile_1 = { + source_profile: "credential_source_profile_2", + role_arn: "ROLE_ARN_2", + }; + + iniProfileData.credential_source_profile_2 = { + credential_source: "EcsContainer", + // This scenario tests the option of having no role_arn in this step of the chain. + }; + + const spy = jest.spyOn(credentialProviderHttp, "fromHttp"); + sts = new STS({ + region: "us-west-2", + requestHandler: mockRequestHandler, + credentials: defaultProvider({ + awsContainerCredentialsFullUri: process.env.AWS_CONTAINER_CREDENTIALS_FULL_URI, + awsContainerAuthorizationToken: process.env.AWS_CONTAINER_AUTHORIZATION_TOKEN, + clientConfig: { + region: "us-west-2", + }, + logger: sink, }), }); await sts.getCallerIdentity({}); @@ -546,6 +699,7 @@ describe("credential-provider-node integration test", () => { awsContainerAuthorizationToken: process.env.AWS_CONTAINER_AUTHORIZATION_TOKEN, }) ); + expect(assumeRoleArns).toEqual(["ROLE_ARN_2", "ROLE_ARN_3"]); spy.mockClear(); }); });