Skip to content

Commit

Permalink
fix(credential-provider-ini): fix recursive assume role and optional …
Browse files Browse the repository at this point in the history
…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
  • Loading branch information
kuhe authored Sep 13, 2024
1 parent 685f44d commit c095306
Show file tree
Hide file tree
Showing 4 changed files with 223 additions and 36 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -140,43 +140,62 @@ export const resolveAssumeRoleCredentials = async (
const sourceCredsProvider: Promise<AwsCredentialIdentity> = 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;
};
12 changes: 10 additions & 2 deletions packages/credential-provider-ini/src/resolveProfileData.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,15 @@ export const resolveProfileData = async (
profileName: string,
profiles: ParsedIniData,
options: FromIniInit,
visitedProfiles: Record<string, true> = {}
visitedProfiles: Record<string, true> = {},
/**
* 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<AwsCredentialIdentity> => {
const data = profiles[profileName];

Expand All @@ -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);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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> | any | undefined = undefined;
const assumeRoleArns: string[] = [];

jest.mock("@aws-sdk/client-sts", () => {
const actual = jest.requireActual("@aws-sdk/client-sts");
Expand All @@ -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",
Expand All @@ -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",
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -257,6 +276,8 @@ describe("credential-provider-node integration test", () => {
output: "json",
},
};
assumeRoleArns.length = 0;
sink.data.length = 0;
});

afterAll(async () => {
Expand Down Expand Up @@ -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";
Expand All @@ -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({});
Expand All @@ -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();
});
});
Expand Down

0 comments on commit c095306

Please sign in to comment.