Skip to content

Commit c095306

Browse files
authored
fix(credential-provider-ini): fix recursive assume role and optional role_arn in credential_source (#6472)
* fix(credential-provider-ini): fix recursive assume role and optional role_arn in credential_source * test(credential-provider-ini): fix mock call verification * test(credential-provider-node): add test case with chained web id token file
1 parent 685f44d commit c095306

File tree

4 files changed

+223
-36
lines changed

4 files changed

+223
-36
lines changed

packages/credential-provider-ini/src/resolveAssumeRoleCredentials.spec.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -169,9 +169,15 @@ describe(resolveAssumeRoleCredentials.name, () => {
169169

170170
const receivedCreds = await resolveAssumeRoleCredentials(mockProfileCurrent, mockProfilesWithSource, mockOptions);
171171
expect(receivedCreds).toStrictEqual(mockCreds);
172-
expect(resolveProfileData).toHaveBeenCalledWith(mockProfileName, mockProfilesWithSource, mockOptions, {
173-
mockProfileName: true,
174-
});
172+
expect(resolveProfileData).toHaveBeenCalledWith(
173+
mockProfileName,
174+
mockProfilesWithSource,
175+
mockOptions,
176+
{
177+
mockProfileName: true,
178+
},
179+
false
180+
);
175181
expect(resolveCredentialSource).not.toHaveBeenCalled();
176182
expect(mockOptions.roleAssumer).toHaveBeenCalledWith(mockSourceCredsFromProfile, {
177183
RoleArn: mockRoleAssumeParams.role_arn,

packages/credential-provider-ini/src/resolveAssumeRoleCredentials.ts

Lines changed: 49 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { CredentialsProviderError } from "@smithy/property-provider";
22
import { getProfileName } from "@smithy/shared-ini-file-loader";
3-
import { AwsCredentialIdentity, Logger, ParsedIniData, Profile } from "@smithy/types";
3+
import { AwsCredentialIdentity, IniSection, Logger, ParsedIniData, Profile } from "@smithy/types";
44

55
import { FromIniInit } from "./fromIni";
66
import { resolveCredentialSource } from "./resolveCredentialSource";
@@ -140,43 +140,62 @@ export const resolveAssumeRoleCredentials = async (
140140
const sourceCredsProvider: Promise<AwsCredentialIdentity> = source_profile
141141
? resolveProfileData(
142142
source_profile,
143-
{
144-
...profiles,
145-
[source_profile]: {
146-
...profiles[source_profile],
147-
// This assigns the role_arn of the "root" profile
148-
// to the credential_source profile so this recursive call knows
149-
// what role to assume.
150-
role_arn: data.role_arn ?? profiles[source_profile].role_arn,
151-
},
152-
},
143+
profiles,
153144
options,
154145
{
155146
...visitedProfiles,
156147
[source_profile]: true,
157-
}
148+
},
149+
isCredentialSourceWithoutRoleArn(profiles[source_profile!] ?? {})
158150
)
159151
: (await resolveCredentialSource(data.credential_source!, profileName, options.logger)(options))();
160152

161-
const params: AssumeRoleParams = {
162-
RoleArn: data.role_arn!,
163-
RoleSessionName: data.role_session_name || `aws-sdk-js-${Date.now()}`,
164-
ExternalId: data.external_id,
165-
DurationSeconds: parseInt(data.duration_seconds || "3600", 10),
166-
};
167-
168-
const { mfa_serial } = data;
169-
if (mfa_serial) {
170-
if (!options.mfaCodeProvider) {
171-
throw new CredentialsProviderError(
172-
`Profile ${profileName} requires multi-factor authentication, but no MFA code callback was provided.`,
173-
{ logger: options.logger, tryNextLink: false }
174-
);
153+
if (isCredentialSourceWithoutRoleArn(data)) {
154+
/**
155+
* This control-flow branch is accessed when in a chained source_profile
156+
* scenario, and the last step of the chain is a credential_source
157+
* without its own role_arn. In this case, we return the credentials
158+
* of the credential_source so that the previous recursive layer
159+
* can use its role_arn instead of redundantly needing another role_arn at
160+
* this final layer.
161+
*/
162+
return sourceCredsProvider;
163+
} else {
164+
const params: AssumeRoleParams = {
165+
RoleArn: data.role_arn!,
166+
RoleSessionName: data.role_session_name || `aws-sdk-js-${Date.now()}`,
167+
ExternalId: data.external_id,
168+
DurationSeconds: parseInt(data.duration_seconds || "3600", 10),
169+
};
170+
171+
const { mfa_serial } = data;
172+
if (mfa_serial) {
173+
if (!options.mfaCodeProvider) {
174+
throw new CredentialsProviderError(
175+
`Profile ${profileName} requires multi-factor authentication, but no MFA code callback was provided.`,
176+
{ logger: options.logger, tryNextLink: false }
177+
);
178+
}
179+
params.SerialNumber = mfa_serial;
180+
params.TokenCode = await options.mfaCodeProvider(mfa_serial);
175181
}
176-
params.SerialNumber = mfa_serial;
177-
params.TokenCode = await options.mfaCodeProvider(mfa_serial);
182+
183+
const sourceCreds = await sourceCredsProvider;
184+
return options.roleAssumer!(sourceCreds, params);
178185
}
186+
};
179187

180-
const sourceCreds = await sourceCredsProvider;
181-
return options.roleAssumer!(sourceCreds, params);
188+
/**
189+
* @internal
190+
*
191+
* Returns true when the ini section in question, typically a profile,
192+
* has a credential_source but not a role_arn.
193+
*
194+
* Previously, a role_arn was a required sibling element to credential_source.
195+
* However, this would require a role_arn+source_profile pointed to a
196+
* credential_source to have a second role_arn, resulting in at least two
197+
* calls to assume-role.
198+
*/
199+
const isCredentialSourceWithoutRoleArn = (section: IniSection): boolean => {
200+
return !section.role_arn && !!section.credential_source;
182201
};

packages/credential-provider-ini/src/resolveProfileData.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,15 @@ export const resolveProfileData = async (
1515
profileName: string,
1616
profiles: ParsedIniData,
1717
options: FromIniInit,
18-
visitedProfiles: Record<string, true> = {}
18+
visitedProfiles: Record<string, true> = {},
19+
/**
20+
* This override comes from recursive calls only.
21+
* It is used to flag a recursive profile section
22+
* that does not have a role_arn, e.g. a credential_source
23+
* with no role_arn, as part of a larger recursive assume-role
24+
* call stack, and to re-enter the assume-role resolver function.
25+
*/
26+
isAssumeRoleRecursiveCall = false
1927
): Promise<AwsCredentialIdentity> => {
2028
const data = profiles[profileName];
2129

@@ -28,7 +36,7 @@ export const resolveProfileData = async (
2836

2937
// If this is the first profile visited, role assumption keys should be
3038
// given precedence over static credentials.
31-
if (isAssumeRoleProfile(data, { profile: profileName, logger: options.logger })) {
39+
if (isAssumeRoleRecursiveCall || isAssumeRoleProfile(data, { profile: profileName, logger: options.logger })) {
3240
return resolveAssumeRoleCredentials(profileName, profiles, options, visitedProfiles);
3341
}
3442

packages/credential-provider-node/src/credential-provider-node.integ.spec.ts

Lines changed: 155 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@ jest.mock("@aws-sdk/client-sso", () => {
7272
// This var must be hoisted.
7373
// eslint-disable-next-line no-var
7474
var stsSpy: jest.Spied<any> | any | undefined = undefined;
75+
const assumeRoleArns: string[] = [];
7576

7677
jest.mock("@aws-sdk/client-sts", () => {
7778
const actual = jest.requireActual("@aws-sdk/client-sts");
@@ -80,6 +81,7 @@ jest.mock("@aws-sdk/client-sts", () => {
8081

8182
stsSpy = jest.spyOn(actual.STSClient.prototype, "send").mockImplementation(async function (this: any, command: any) {
8283
if (command.constructor.name === "AssumeRoleCommand") {
84+
assumeRoleArns.push(command.input.RoleArn);
8385
return {
8486
Credentials: {
8587
AccessKeyId: "STS_AR_ACCESS_KEY_ID",
@@ -91,6 +93,7 @@ jest.mock("@aws-sdk/client-sts", () => {
9193
};
9294
}
9395
if (command.constructor.name === "AssumeRoleWithWebIdentityCommand") {
96+
assumeRoleArns.push(command.input.RoleArn);
9497
return {
9598
Credentials: {
9699
AccessKeyId: "STS_ARWI_ACCESS_KEY_ID",
@@ -177,6 +180,22 @@ describe("credential-provider-node integration test", () => {
177180
let sts: STS = null as any;
178181
let processSnapshot: typeof process.env = null as any;
179182

183+
const sink = {
184+
data: [] as string[],
185+
debug(log: string) {
186+
this.data.push(log);
187+
},
188+
info(log: string) {
189+
this.data.push(log);
190+
},
191+
warn(log: string) {
192+
this.data.push(log);
193+
},
194+
error(log: string) {
195+
this.data.push(log);
196+
},
197+
};
198+
180199
const RESERVED_ENVIRONMENT_VARIABLES = {
181200
AWS_DEFAULT_REGION: 1,
182201
AWS_REGION: 1,
@@ -257,6 +276,8 @@ describe("credential-provider-node integration test", () => {
257276
output: "json",
258277
},
259278
};
279+
assumeRoleArns.length = 0;
280+
sink.data.length = 0;
260281
});
261282

262283
afterAll(async () => {
@@ -511,7 +532,7 @@ describe("credential-provider-node integration test", () => {
511532
});
512533
});
513534

514-
it("should be able to combine a source_profile having credential_source with an origin profile having role_arn and source_profile", async () => {
535+
it("should be able to combine a source_profile having only credential_source with an origin profile having role_arn and source_profile", async () => {
515536
process.env.AWS_CONTAINER_CREDENTIALS_FULL_URI = "http://169.254.170.23";
516537
process.env.AWS_CONTAINER_AUTHORIZATION_TOKEN = "container-authorization";
517538
iniProfileData.default.source_profile = "credential_source_profile";
@@ -529,6 +550,138 @@ describe("credential-provider-node integration test", () => {
529550
clientConfig: {
530551
region: "us-west-2",
531552
},
553+
logger: sink,
554+
}),
555+
});
556+
await sts.getCallerIdentity({});
557+
const credentials = await sts.config.credentials();
558+
expect(credentials).toEqual({
559+
accessKeyId: "STS_AR_ACCESS_KEY_ID",
560+
secretAccessKey: "STS_AR_SECRET_ACCESS_KEY",
561+
sessionToken: "STS_AR_SESSION_TOKEN",
562+
expiration: new Date("3000-01-01T00:00:00.000Z"),
563+
credentialScope: "us-stsar-1__us-west-2",
564+
});
565+
expect(spy).toHaveBeenCalledWith(
566+
expect.objectContaining({
567+
awsContainerCredentialsFullUri: process.env.AWS_CONTAINER_CREDENTIALS_FULL_URI,
568+
awsContainerAuthorizationToken: process.env.AWS_CONTAINER_AUTHORIZATION_TOKEN,
569+
})
570+
);
571+
expect(assumeRoleArns).toEqual(["ROLE_ARN"]);
572+
spy.mockClear();
573+
});
574+
575+
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 () => {
576+
iniProfileData.default.source_profile = "credential_source_profile";
577+
iniProfileData.default.role_arn = "ROLE_ARN_2";
578+
579+
iniProfileData.credential_source_profile = {
580+
web_identity_token_file: "token-filepath",
581+
role_arn: "ROLE_ARN_1",
582+
};
583+
584+
sts = new STS({
585+
region: "us-west-2",
586+
requestHandler: mockRequestHandler,
587+
credentials: defaultProvider({
588+
awsContainerCredentialsFullUri: process.env.AWS_CONTAINER_CREDENTIALS_FULL_URI,
589+
awsContainerAuthorizationToken: process.env.AWS_CONTAINER_AUTHORIZATION_TOKEN,
590+
clientConfig: {
591+
region: "us-west-2",
592+
},
593+
logger: sink,
594+
}),
595+
});
596+
await sts.getCallerIdentity({});
597+
const credentials = await sts.config.credentials();
598+
expect(credentials).toEqual({
599+
accessKeyId: "STS_AR_ACCESS_KEY_ID",
600+
secretAccessKey: "STS_AR_SECRET_ACCESS_KEY",
601+
sessionToken: "STS_AR_SESSION_TOKEN",
602+
expiration: new Date("3000-01-01T00:00:00.000Z"),
603+
credentialScope: "us-stsar-1__us-west-2",
604+
});
605+
expect(assumeRoleArns).toEqual(["ROLE_ARN_1", "ROLE_ARN_2"]);
606+
});
607+
608+
it("should complete chained role_arn credentials", async () => {
609+
process.env.AWS_CONTAINER_CREDENTIALS_FULL_URI = "http://169.254.170.23";
610+
process.env.AWS_CONTAINER_AUTHORIZATION_TOKEN = "container-authorization";
611+
612+
iniProfileData.default.source_profile = "credential_source_profile_1";
613+
iniProfileData.default.role_arn = "ROLE_ARN_3";
614+
615+
iniProfileData.credential_source_profile_1 = {
616+
source_profile: "credential_source_profile_2",
617+
role_arn: "ROLE_ARN_2",
618+
};
619+
620+
iniProfileData.credential_source_profile_2 = {
621+
credential_source: "EcsContainer",
622+
role_arn: "ROLE_ARN_1",
623+
};
624+
625+
const spy = jest.spyOn(credentialProviderHttp, "fromHttp");
626+
sts = new STS({
627+
region: "us-west-2",
628+
requestHandler: mockRequestHandler,
629+
credentials: defaultProvider({
630+
awsContainerCredentialsFullUri: process.env.AWS_CONTAINER_CREDENTIALS_FULL_URI,
631+
awsContainerAuthorizationToken: process.env.AWS_CONTAINER_AUTHORIZATION_TOKEN,
632+
clientConfig: {
633+
region: "us-west-2",
634+
},
635+
logger: sink,
636+
}),
637+
});
638+
await sts.getCallerIdentity({});
639+
const credentials = await sts.config.credentials();
640+
expect(credentials).toEqual({
641+
accessKeyId: "STS_AR_ACCESS_KEY_ID",
642+
secretAccessKey: "STS_AR_SECRET_ACCESS_KEY",
643+
sessionToken: "STS_AR_SESSION_TOKEN",
644+
expiration: new Date("3000-01-01T00:00:00.000Z"),
645+
credentialScope: "us-stsar-1__us-west-2",
646+
});
647+
expect(spy).toHaveBeenCalledWith(
648+
expect.objectContaining({
649+
awsContainerCredentialsFullUri: process.env.AWS_CONTAINER_CREDENTIALS_FULL_URI,
650+
awsContainerAuthorizationToken: process.env.AWS_CONTAINER_AUTHORIZATION_TOKEN,
651+
})
652+
);
653+
expect(assumeRoleArns).toEqual(["ROLE_ARN_1", "ROLE_ARN_2", "ROLE_ARN_3"]);
654+
spy.mockClear();
655+
});
656+
657+
it("should complete chained role_arn credentials with optional role_arn in credential_source step", async () => {
658+
process.env.AWS_CONTAINER_CREDENTIALS_FULL_URI = "http://169.254.170.23";
659+
process.env.AWS_CONTAINER_AUTHORIZATION_TOKEN = "container-authorization";
660+
661+
iniProfileData.default.source_profile = "credential_source_profile_1";
662+
iniProfileData.default.role_arn = "ROLE_ARN_3";
663+
664+
iniProfileData.credential_source_profile_1 = {
665+
source_profile: "credential_source_profile_2",
666+
role_arn: "ROLE_ARN_2",
667+
};
668+
669+
iniProfileData.credential_source_profile_2 = {
670+
credential_source: "EcsContainer",
671+
// This scenario tests the option of having no role_arn in this step of the chain.
672+
};
673+
674+
const spy = jest.spyOn(credentialProviderHttp, "fromHttp");
675+
sts = new STS({
676+
region: "us-west-2",
677+
requestHandler: mockRequestHandler,
678+
credentials: defaultProvider({
679+
awsContainerCredentialsFullUri: process.env.AWS_CONTAINER_CREDENTIALS_FULL_URI,
680+
awsContainerAuthorizationToken: process.env.AWS_CONTAINER_AUTHORIZATION_TOKEN,
681+
clientConfig: {
682+
region: "us-west-2",
683+
},
684+
logger: sink,
532685
}),
533686
});
534687
await sts.getCallerIdentity({});
@@ -546,6 +699,7 @@ describe("credential-provider-node integration test", () => {
546699
awsContainerAuthorizationToken: process.env.AWS_CONTAINER_AUTHORIZATION_TOKEN,
547700
})
548701
);
702+
expect(assumeRoleArns).toEqual(["ROLE_ARN_2", "ROLE_ARN_3"]);
549703
spy.mockClear();
550704
});
551705
});

0 commit comments

Comments
 (0)