Skip to content

Commit ad978ea

Browse files
committed
feat(cli): MFA support
With this change AWS CDK supports MFA. Specifically it will support mfa_serial field in the profile config by asking user for MFA token for the mfa_serial field ARN. AWS SDK has support for this built in so only change is adding tokenCodeFn function to sharedIniCredentials options. Callback sent to that function is used to return token back to SDK. Inquirer package is used to create interactive prompt for user to type the MFA token.
1 parent 375335e commit ad978ea

File tree

3 files changed

+67
-3
lines changed

3 files changed

+67
-3
lines changed

packages/aws-cdk/README.md

+13
Original file line numberDiff line numberDiff line change
@@ -266,6 +266,19 @@ $ cdk doctor
266266
- AWS_SDK_LOAD_CONFIG = 1
267267
```
268268

269+
### MFA support
270+
271+
If `mfa_serial` is found in the active profile of the shared ini file AWS CDK
272+
will ask for token defined in the `mfa_serial`. This token will be provided to STS assume role call.
273+
274+
Example profile in `~/.aws/config` where `mfa_serial` is used to assume role:
275+
```ini
276+
[profile my_assume_role_profile]
277+
source_profile=my_source_role
278+
role_arn=arn:aws:iam::123456789123:role/role_to_be_assumed
279+
mfa_serial=arn:aws:iam::123456789123:mfa/my_user
280+
```
281+
269282
### Configuration
270283
On top of passing configuration through command-line arguments, it is possible to use JSON configuration files. The
271284
configuration's order of precedence is:

packages/aws-cdk/lib/api/aws-auth/awscli-compatible.ts

+24-3
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import * as path from 'path';
44
import * as util from 'util';
55
import * as AWS from 'aws-sdk';
66
import * as fs from 'fs-extra';
7+
import * as promptly from 'promptly';
78
import { debug } from '../../logging';
89
import { SharedIniFile } from './sdk_ini_file';
910

@@ -45,11 +46,11 @@ export class AwsCliCompatible {
4546
];
4647

4748
if (await fs.pathExists(credentialsFileName())) {
48-
sources.push(() => new AWS.SharedIniFileCredentials({ profile, filename: credentialsFileName(), httpOptions }));
49+
sources.push(() => new AWS.SharedIniFileCredentials({ profile, filename: credentialsFileName(), httpOptions, tokenCodeFn }));
4950
}
5051

5152
if (await fs.pathExists(configFileName())) {
52-
sources.push(() => new AWS.SharedIniFileCredentials({ profile, filename: credentialsFileName(), httpOptions }));
53+
sources.push(() => new AWS.SharedIniFileCredentials({ profile, filename: credentialsFileName(), httpOptions, tokenCodeFn }));
5354
}
5455

5556
if (containerCreds ?? hasEcsCredentials()) {
@@ -200,4 +201,24 @@ function readIfPossible(filename: string): string | undefined {
200201
debug(e);
201202
return undefined;
202203
}
203-
}
204+
}
205+
206+
/**
207+
* Ask user for MFA token for given serial
208+
*
209+
* Result is send to callback function for SDK to authorize the request
210+
*/
211+
async function tokenCodeFn(serialArn: string, cb: (err?: Error, token?: string) => void): Promise<void> {
212+
debug('Require MFA token for serial ARN', serialArn);
213+
try {
214+
const token: string = await promptly.prompt(`MFA token for ${serialArn}: `, {
215+
trim: true,
216+
default: '',
217+
});
218+
debug('Successfully got MFA token from user');
219+
cb(undefined, token);
220+
} catch (err) {
221+
debug('Failed to get MFA token', err);
222+
cb(err);
223+
}
224+
}

packages/aws-cdk/test/api/sdk-provider.test.ts

+30
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,11 @@ import { ISDK, Mode, SdkProvider } from '../../lib/api/aws-auth';
88
import * as logging from '../../lib/logging';
99
import * as bockfs from '../bockfs';
1010

11+
// Mock promptly prompt to test MFA support
12+
jest.mock('promptly', () => ({
13+
prompt: jest.fn().mockRejectedValue(new Error('test')),
14+
}));
15+
1116
SDKMock.setSDKInstance(AWS);
1217

1318
type AwsCallback<T> = (err: Error | null, val: T) => void;
@@ -40,6 +45,10 @@ beforeEach(() => {
4045
[assumer]
4146
aws_access_key_id=${uid}assumer
4247
aws_secret_access_key=secret
48+
49+
[mfa]
50+
aws_access_key_id=${uid}mfaccess
51+
aws_secret_access_key=secret
4352
`),
4453
'/home/me/.bxt/config': dedent(`
4554
[default]
@@ -59,6 +68,14 @@ beforeEach(() => {
5968
6069
[profile assumer]
6170
region=us-east-2
71+
72+
[profile mfa]
73+
region=eu-west-1
74+
75+
[profile mfa-role]
76+
source_profile=mfa
77+
role_arn=arn:aws:iam::account:role/role
78+
mfa_serial=arn:aws:iam::account:mfa/user
6279
`),
6380
});
6481

@@ -150,6 +167,19 @@ describe('CLI compatible credentials loading', () => {
150167
expect(sdkConfig(sdk).credentials!.accessKeyId).toEqual(`${uid}booccess`);
151168
});
152169

170+
test('mfa_serial in profile will ask user for token', async () => {
171+
// WHEN
172+
const provider = await SdkProvider.withAwsCliCompatibleDefaults({ ...defaultCredOptions, profile: 'mfa-role' });
173+
174+
// THEN
175+
try {
176+
await provider.withAssumedRole('arn:aws:iam::account:role/role', undefined, undefined);
177+
} catch (e) {
178+
// Mock response was set to fail with message test to make sure we don't call STS
179+
expect(e.message).toEqual('Error fetching MFA token: test');
180+
}
181+
});
182+
153183
test('different account throws', async () => {
154184
const provider = await SdkProvider.withAwsCliCompatibleDefaults({ ...defaultCredOptions, profile: 'boo' });
155185

0 commit comments

Comments
 (0)