From ad978ea9790f2d9d8f86ae8ffdf3c12fdf5f93bc Mon Sep 17 00:00:00 2001 From: Niko Lehtovirta Date: Sun, 24 May 2020 17:48:52 +0300 Subject: [PATCH] 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. --- packages/aws-cdk/README.md | 13 ++++++++ .../lib/api/aws-auth/awscli-compatible.ts | 27 +++++++++++++++-- .../aws-cdk/test/api/sdk-provider.test.ts | 30 +++++++++++++++++++ 3 files changed, 67 insertions(+), 3 deletions(-) diff --git a/packages/aws-cdk/README.md b/packages/aws-cdk/README.md index 863543b606d93..7a4695cf8280c 100644 --- a/packages/aws-cdk/README.md +++ b/packages/aws-cdk/README.md @@ -266,6 +266,19 @@ $ cdk doctor - AWS_SDK_LOAD_CONFIG = 1 ``` +### MFA support + +If `mfa_serial` is found in the active profile of the shared ini file AWS CDK +will ask for token defined in the `mfa_serial`. This token will be provided to STS assume role call. + +Example profile in `~/.aws/config` where `mfa_serial` is used to assume role: +```ini +[profile my_assume_role_profile] +source_profile=my_source_role +role_arn=arn:aws:iam::123456789123:role/role_to_be_assumed +mfa_serial=arn:aws:iam::123456789123:mfa/my_user +``` + ### Configuration On top of passing configuration through command-line arguments, it is possible to use JSON configuration files. The configuration's order of precedence is: diff --git a/packages/aws-cdk/lib/api/aws-auth/awscli-compatible.ts b/packages/aws-cdk/lib/api/aws-auth/awscli-compatible.ts index 0baeaa323f81e..b8f4c98e907b3 100644 --- a/packages/aws-cdk/lib/api/aws-auth/awscli-compatible.ts +++ b/packages/aws-cdk/lib/api/aws-auth/awscli-compatible.ts @@ -4,6 +4,7 @@ import * as path from 'path'; import * as util from 'util'; import * as AWS from 'aws-sdk'; import * as fs from 'fs-extra'; +import * as promptly from 'promptly'; import { debug } from '../../logging'; import { SharedIniFile } from './sdk_ini_file'; @@ -45,11 +46,11 @@ export class AwsCliCompatible { ]; if (await fs.pathExists(credentialsFileName())) { - sources.push(() => new AWS.SharedIniFileCredentials({ profile, filename: credentialsFileName(), httpOptions })); + sources.push(() => new AWS.SharedIniFileCredentials({ profile, filename: credentialsFileName(), httpOptions, tokenCodeFn })); } if (await fs.pathExists(configFileName())) { - sources.push(() => new AWS.SharedIniFileCredentials({ profile, filename: credentialsFileName(), httpOptions })); + sources.push(() => new AWS.SharedIniFileCredentials({ profile, filename: credentialsFileName(), httpOptions, tokenCodeFn })); } if (containerCreds ?? hasEcsCredentials()) { @@ -200,4 +201,24 @@ function readIfPossible(filename: string): string | undefined { debug(e); return undefined; } -} \ No newline at end of file +} + +/** + * Ask user for MFA token for given serial + * + * Result is send to callback function for SDK to authorize the request + */ +async function tokenCodeFn(serialArn: string, cb: (err?: Error, token?: string) => void): Promise { + debug('Require MFA token for serial ARN', serialArn); + try { + const token: string = await promptly.prompt(`MFA token for ${serialArn}: `, { + trim: true, + default: '', + }); + debug('Successfully got MFA token from user'); + cb(undefined, token); + } catch (err) { + debug('Failed to get MFA token', err); + cb(err); + } +} diff --git a/packages/aws-cdk/test/api/sdk-provider.test.ts b/packages/aws-cdk/test/api/sdk-provider.test.ts index 0d50a3936053c..3ddfb731b66a8 100644 --- a/packages/aws-cdk/test/api/sdk-provider.test.ts +++ b/packages/aws-cdk/test/api/sdk-provider.test.ts @@ -8,6 +8,11 @@ import { ISDK, Mode, SdkProvider } from '../../lib/api/aws-auth'; import * as logging from '../../lib/logging'; import * as bockfs from '../bockfs'; +// Mock promptly prompt to test MFA support +jest.mock('promptly', () => ({ + prompt: jest.fn().mockRejectedValue(new Error('test')), +})); + SDKMock.setSDKInstance(AWS); type AwsCallback = (err: Error | null, val: T) => void; @@ -40,6 +45,10 @@ beforeEach(() => { [assumer] aws_access_key_id=${uid}assumer aws_secret_access_key=secret + + [mfa] + aws_access_key_id=${uid}mfaccess + aws_secret_access_key=secret `), '/home/me/.bxt/config': dedent(` [default] @@ -59,6 +68,14 @@ beforeEach(() => { [profile assumer] region=us-east-2 + + [profile mfa] + region=eu-west-1 + + [profile mfa-role] + source_profile=mfa + role_arn=arn:aws:iam::account:role/role + mfa_serial=arn:aws:iam::account:mfa/user `), }); @@ -150,6 +167,19 @@ describe('CLI compatible credentials loading', () => { expect(sdkConfig(sdk).credentials!.accessKeyId).toEqual(`${uid}booccess`); }); + test('mfa_serial in profile will ask user for token', async () => { + // WHEN + const provider = await SdkProvider.withAwsCliCompatibleDefaults({ ...defaultCredOptions, profile: 'mfa-role' }); + + // THEN + try { + await provider.withAssumedRole('arn:aws:iam::account:role/role', undefined, undefined); + } catch (e) { + // Mock response was set to fail with message test to make sure we don't call STS + expect(e.message).toEqual('Error fetching MFA token: test'); + } + }); + test('different account throws', async () => { const provider = await SdkProvider.withAwsCliCompatibleDefaults({ ...defaultCredOptions, profile: 'boo' });