AWS SDK for JavaScriptで、「多要素認証(MFA)をしてAssumeRole(スイッチロール)」するスクリプトを書いてみた

Jumpアカウント環境でもAWS SDKを使って操作したかったので、AWS SDK for JavaScript でスクリプト(TypeScript)を書いてみました。
2021.06.05

AWS SDK for JavaScriptで簡単に「多要素認証(MFA)をしてAssumeRole(スイッチロール)」したい

こんにちは、のん です。

皆さんはAWS SDK for JavaScriptで以下のように「多要素認証(MFA)をしてAssumeRole(スイッチロール)」したいと思ったことはありますか? 私はあります。

AWS CLIの場合は、以下ブログで紹介している通り、~/.aws/configを設定すれば簡単にスイッチロールして操作ができます。

しかし、それでも私はAWS SDK for JavaScriptを使いたいのです。その理由は以下の通りです。

  • npmで公開されている様々なパッケージを使って楽してスクリプトを書きたい
  • 慣れている言語でスクリプトを書きたい
  • AWS CLIを使う場合には、シェルスクリプトの知識が必要で、シェルスクリプトにはちょっと苦手意識がある

そこで、AWS SDK for Python版のスクリプトは以下ブログで紹介されているので、JavaScript(TypeScript)版のスクリプトを今回書いてみようと思います。

スクリプトの説明

スクリプトのディレクトリ構成は以下の通りです。

> tree
.
├── dst
├── package-lock.json
├── package.json
├── src
│   ├── get-credentials.ts
│   └── library
│       └── sts.ts
└── tsconfig.json

3 directories, 5 files

メインで実行するスクリプトは./src/get-credentials.tsになります。

処理の流れは以下の通りです。

  1. 引数で、スイッチロール先のIAMロールのプロファイル名と、リージョン名を指定する
    1. 引数に不足があれば、処理を終了する
  2. importしたgetCredentials()を実行し、指定したプロファイル名で認証情報を取得する
  3. 取得した認証情報を設定し、EC2インスタンスの一覧を取得する

認証情報を取得した後、わざわざ手動で環境変数に設定するような仕組みにしたくなかったので、スクリプトで全ての処理が完結するように書いてみました。

また、今回動作させるスクリプトは例として、3. 取得した認証情報を設定し、EC2インスタンスの一覧を取得するとしましたが、3.以降は任意の処理を記載していただければと思います。

実際のスクリプトは以下の通りです。

./src/get-credentials.ts

import * as AWS from "aws-sdk";
import { getCredentials } from "./library/sts";

const main = async () => {
  if (!process.argv[2] || !process.argv[3]) {
    console.log("Enter the profile name and region name as arguments.");
    console.log(`e.g. node ${process.argv[1]} non-97 us-east-1`);
    return;
  }

  // Set profile name
  const profileName: string = process.argv[2];

  // Set region name
  const regionName: string = process.argv[3];

  // Get Credntials
  const awsCredentials = await getCredentials(profileName);
  if (!awsCredentials) return;

  // Set Credentials for EC2 Client
  const ec2 = new AWS.EC2({
    accessKeyId: awsCredentials.accessKeyId,
    secretAccessKey: awsCredentials.secretAccessKey,
    sessionToken: awsCredentials.sessionToken,
    region: regionName,
  });

  // Describe EC2 Instances
  ec2.describeInstances((error, data) => {
    if (error) {
      console.dir(error);
    } else {
      console.log("ec2.describeInstances");
      console.dir(data);
    }
  });
};

main();

それでは、実際のスイッチロールのキモになる、./src/library/sts.tsについて説明をします。

全体的な処理の流れは以下の通りです。

  1. 引数とした渡されたプロファイル名を元に、getRoleProfile()を呼び出して、スイッチロールするためのプロファイルの情報を取得する
    1. プロファイル情報が記載されている~/.aws/configを読み取る
    2. ~/.aws/configに指定したプロファイル名がなければ処理を終了する
    3. 指定したプロファイル名のMFAのシリアルナンバーとIAMロールのARNを取得し、戻り値として返却する
  2. 取得したプロファイル情報を元に、assumeRole()を呼び出して、一時的な認証情報を取得する
    1. readInput()を呼び出し、利用者からMFAのトークンの入力を受け取る
    2. 入力されたMFAトークンとIAMロールのARNからAsuumeRole(スイッチロール)をして、一時的な認証情報を習得し、戻り値として返却する
  3. 取得した一時的な認証情報を呼び出し元に返却する

また、~/.aws/configは以下のように複数のプロファイルを登録しています。

~/.aws/config

[default]
region = us-east-1
output = yaml

[profile non-97]
region = us-east-1
mfa_serial = arn:aws:iam::<スイッチロール元のAWSアカウントID>:mfa/<スイッチロール元のIAMユーザー名>
role_arn = arn:aws:iam::<スイッチロール先のAWSアカウントID>:role/non-97
source_profile = default

[profile cm-non-97]
region = us-east-1
mfa_serial = arn:aws:iam::<スイッチロール元のAWSアカウントID>:mfa/<スイッチロール元のIAMユーザー名>
role_arn = arn:aws:iam::<スイッチロール先のAWSアカウントID>:role/cm-non-97
source_profile = default

実際の./src/library/sts.tsは以下の通りです。

./src/library/sts.ts

import * as AWS from "aws-sdk";
import { createInterface } from "readline";
import * as fs from "fs";

interface RoleProfile {
  mfaSerial: string;
  roleArn: string;
}

// Get Credentials
export const getCredentials = async (
  profileName: string
): Promise<AWS.Credentials | undefined> => {
  const roleProfile = await getRoleProfile(profileName);
  if (!roleProfile) return undefined;

  const credentials = await assumeRole(roleProfile);
  if (!credentials) return undefined;

  return new AWS.Credentials({
    accessKeyId: credentials.AccessKeyId,
    secretAccessKey: credentials.SecretAccessKey,
    sessionToken: credentials.SessionToken,
  });
};

// Get IAM Role profile
export const getRoleProfile = async (
  profileName: string
): Promise<RoleProfile | undefined> => {
  return new Promise((resolve, reject) => {
    // Read ~/.aws/config
    const awsConfig = fs.readFileSync(
      `${process.env.HOME}/.aws/config`,
      "utf8"
    );
    const awsConfigLines = awsConfig.toString().split("\n");

    const profileNameIndex = awsConfigLines.indexOf(`[profile ${profileName}]`);

    if (profileNameIndex == -1) {
      reject(
        `There were no matching profiles in "${process.env.HOME}/.aws/config".`
      );
    }

    // Get mfa serial of the profile
    const mfaSerialLine = awsConfigLines.filter(
      (line: string, index: number) =>
        line.indexOf("mfa_serial = ") === 0 && index > profileNameIndex
    )[0];
    const mfaSerialIndex = mfaSerialLine.indexOf(" = ") + 3;
    const mfaSerial = mfaSerialLine.slice(mfaSerialIndex);

    // Get IAM Role Arn of the profile
    const roleArnlLine = awsConfigLines.filter(
      (line: string, index: number) =>
        line.indexOf("role_arn = ") === 0 && index > profileNameIndex
    )[0];
    const roleArnIndex = roleArnlLine.indexOf(" = ") + 3;
    const roleArn = roleArnlLine.slice(roleArnIndex);

    resolve({
      mfaSerial: mfaSerial,
      roleArn: roleArn,
    });
  });
};

// Assume Role
export const assumeRole = async (
  roleProfile: RoleProfile
): Promise<AWS.STS.Credentials | undefined> => {
  const sts = new AWS.STS();

  // Input MFA Token
  const mfaToken = await readInput(`MFA token for ${roleProfile.mfaSerial} > `);

  return new Promise((resolve, reject) => {
    const params: AWS.STS.AssumeRoleRequest = {
      RoleArn: roleProfile.roleArn,
      SerialNumber: roleProfile.mfaSerial,
      TokenCode: <string>mfaToken,
      RoleSessionName: new Date().getTime().toString(),
      DurationSeconds: 900,
    };
    sts.assumeRole(params, (error, data) => {
      if (error) reject(error);
      console.dir(data);
      resolve(data?.Credentials);
    });
  });
};

// Read Input
export const readInput = async (
  questionText: string
): Promise<string | undefined> => {
  const readline = createInterface({
    input: process.stdin,
    output: process.stdout,
  });

  return new Promise((resolve, reject) => {
    readline.question(questionText, (answerText) => {
      resolve(answerText);
      readline.close();
    });
  });
};

また、今回TypeScriptでスクリプトを書いたので、以下のように./tsconfig.jsonを記述しました。
./src/配下のスクリプトをコンパイルした結果は、./dst/に出力するようにしました。

./tsconfig.json

{
  "compilerOptions": {
    "target": "ES2018",
    "module": "commonjs",
    "lib": ["es2018"],
    "declaration": true,
    "strict": true,
    "noImplicitAny": true,
    "strictNullChecks": true,
    "noImplicitThis": true,
    "alwaysStrict": true,
    "noUnusedLocals": false,
    "noUnusedParameters": false,
    "noImplicitReturns": true,
    "noFallthroughCasesInSwitch": false,
    "inlineSourceMap": true,
    "inlineSources": true,
    "experimentalDecorators": true,
    "strictPropertyInitialization": false,
    "typeRoots": ["./node_modules/@types"],
    "rootDir": "./src/",
    "outDir": "./dst/"
  },
}

また、./package.jsonは以下の通りです。

./package.json

{
  "name": "test",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "author": "",
  "license": "ISC",
  "dependencies": {
    "@types/node": "^15.12.1",
    "aws-sdk": "^2.922.0",
    "fs": "^0.0.1-security",
    "typescript": "^4.3.2"
  }
}

やってみた

それでは、実際にスクリプトを実行してEC2インスタンスの一覧を取得してみます。また、失敗パターンとして、以下パターンも試してみました。

  1. 数にスイッチロール先のプロファイル名とリージョン名を入力していない
  2. ~/.aws/configに存在しないプロファイル名を指定した
  3. MFAで適当な値を入力した
  4. スイッチロール先のIAMロールにIAMポリシーをアタッチしなかった

実際の操作のログは以下の通りです。

# TypeScriptのコンパイル
> npx tsc

# ./dst/ 配下にコンパイルした結果が出力されたか確認
> tree 
.
├── dst
│   ├── get-credentials.d.ts
│   ├── get-credentials.js
│   └── library
│       ├── sts.d.ts
│       └── sts.js
├── package-lock.json
├── package.json
├── src
│   ├── get-credentials.ts
│   └── library
│       └── sts.ts
└── tsconfig.json

4 directories, 9 files

# 失敗パターン
# 引数にスイッチロール先のプロファイル名とリージョン名を入力していない
> node ./dst/get-credentials.js
Enter the profile name as arguments.
e.g. node /<ディレクトリ名>/dst/get-credentials.js non-97 us-east-1

# ~/.aws/configに存在しないプロファイル名を指定した
> node ./dst/get-credentials.js non-97 us-east-1 
(node:29478) UnhandledPromiseRejectionWarning: There were no matching profiles in "/<ディレクトリ名>/.aws/config".
(Use `node --trace-warnings ...` to show where the warning was created)
(node:29478) UnhandledPromiseRejectionWarning: Unhandled promise rejection. This error originated either by throwing inside of an async function without a catch block, or by rejecting a promise which was not handled with .catch(). To terminate the node process on unhandled promise rejection, use the CLI flag `--unhandled-rejections=strict` (see https://nodejs.org/api/cli.html#cli_unhandled_rejections_mode). (rejection id: 2)
(node:29478) [DEP0018] DeprecationWarning: Unhandled promise rejections are deprecated. In the future, promise rejections that are not handled will terminate the Node.js process with a non-zero exit code.

# MFAで適当な値を入力した
> node ./dst/get-credentials.js non-97 us-east-1
MFA token for arn:aws:iam::<スイッチロール元のAWSアカウントID>:mfa/<スイッチロール元のIAMユーザー名> > 123456
null
(node:27859) UnhandledPromiseRejectionWarning: AccessDenied: MultiFactorAuthentication failed with invalid MFA one time pass code.
    at Request.extractError (/<ディレクトリ名>/node_modules/aws-sdk/lib/protocol/query.js:50:29)
    at Request.callListeners (/<ディレクトリ名>/node_modules/aws-sdk/lib/sequential_executor.js:106:20)
    at Request.emit (/<ディレクトリ名>/node_modules/aws-sdk/lib/sequential_executor.js:78:10)
    at Request.emit (/<ディレクトリ名>/node_modules/aws-sdk/lib/request.js:688:14)
    at Request.transition (/<ディレクトリ>/node_modules/aws-sdk/lib/request.js:22:10)
    at AcceptorStateMachine.runTo (/<ディレクトリ名>/node_modules/aws-sdk/lib/state_machine.js:14:12)
    at /<ディレクトリ名>/node_modules/aws-sdk/lib/state_machine.js:26:10
    at Request.<anonymous> (/<ディレクトリ名>/node_modules/aws-sdk/lib/request.js:38:9)
    at Request.<anonymous> (/<ディレクトリ名>/node_modules/aws-sdk/lib/request.js:690:12)
    at Request.callListeners (/<ディレクトリ名>/node_modules/aws-sdk/lib/sequential_executor.js:116:18)
(Use `node --trace-warnings ...` to show where the warning was created)
(node:27859) UnhandledPromiseRejectionWarning: Unhandled promise rejection. This error originated either by throwing inside of an async function without a catch block, or by rejecting a promise which was not handled with .catch(). To terminate the node process on unhandled promise rejection, use the CLI flag `--unhandled-rejections=strict` (see https://nodejs.org/api/cli.html#cli_unhandled_rejections_mode). (rejection id: 1)
(node:27859) [DEP0018] DeprecationWarning: Unhandled promise rejections are deprecated. In the future, promise rejections that are not handled will terminate the Node.js process with a non-zero exit code.

# スイッチロール先のIAMロールにIAMポリシーをアタッチしなかった
> node ./dst/get-credentials.js cm-non-97 us-east-1
MFA token for arn:aws:iam::<スイッチロール元のAWSアカウントID>:mfa/<スイッチロール元のIAMユーザー名> > 214826
{
  ResponseMetadata: { RequestId: '376aa6d1-6531-4045-b89c-91ae5466fd59' },
  Credentials: {
    AccessKeyId: 'ASIA6KUFAVPU3HQ3NCW3',
    SecretAccessKey: 'elo+Ku1FCPK9XdJUZlWFaaVhL1jgRn4ztUzuQ0Kp',
    SessionToken: 'FwoGZXIvYXdzEIb//////////wEaDLSdWJL/iNPX7X4QHCKxAaHssRMkwqQi7NX7x09/Gg01z/u/DPizdOOFHfOxGQohSG+xb7OVrA70nHHE0/OlJn7zs1XbBSTiklqAspb5aulJCB4og39jsNSfh8l06svBABkUGyzGqLd0U+gpMp0m2YSyPx24M74VZ72OKMXdLUZZbG2WpFB8NWEPuExmo6NGXsR2bQu11orWSFY+FsIaz6xROGO9GYj5wJoTr7vszfvDBSpn0yHHkYSo02TLa5JOryj53+2FBjItksLV6g+05tAVipIlcLyRsWPBjCupWFT3vbT30pVKjITghD+0dfDnlf3OX329',
    Expiration: 2021-06-05T12:52:13.000Z
  },
  AssumedRoleUser: {
    AssumedRoleId: 'AROA6KUFAVPU76CP4MFFP:1622896632154',
    Arn: 'arn:aws:sts::<スイッチロール先のAWSアカウントID>:assumed-role/cm-non-97/1622896632154'
  }
}
UnauthorizedOperation: You are not authorized to perform this operation.
    at Request.extractError (/U<ディレクトリ名>/node_modules/aws-sdk/lib/services/ec2.js:50:35)
    at Request.callListeners (/U<ディレクトリ名>/node_modules/aws-sdk/lib/sequential_executor.js:106:20)
    at Request.emit (/U<ディレクトリ名>/node_modules/aws-sdk/lib/sequential_executor.js:78:10)
    at Request.emit (/U<ディレクトリ名>/node_modules/aws-sdk/lib/request.js:688:14)
    at Request.transition (/U<ディレクトリ名>/node_modules/aws-sdk/lib/request.js:22:10)
    at AcceptorStateMachine.runTo (/U<ディレクトリ名>/node_modules/aws-sdk/lib/state_machine.js:14:12)
    at /U<ディレクトリ名>/node_modules/aws-sdk/lib/state_machine.js:26:10
    at Request.<anonymous> (/U<ディレクトリ名>/node_modules/aws-sdk/lib/request.js:38:9)
    at Request.<anonymous> (/U<ディレクトリ名>/node_modules/aws-sdk/lib/request.js:690:12)
    at Request.callListeners (/U<ディレクトリ名>/node_modules/aws-sdk/lib/sequential_executor.js:116:18) {
  code: 'UnauthorizedOperation',
  time: 2021-06-05T12:37:14.294Z,
  requestId: 'f882b3e9-14d7-4d36-8772-04f46494b473',
  statusCode: 403,
  retryable: false,
  retryDelay: 47.105687166250235
}

# 成功パターン
> node ./dst/get-credentials.js non-97 us-east-1
MFA token for arn:aws:iam::<スイッチロール元のAWSアカウントID>:mfa/<スイッチロール元のIAMユーザー名> > 379666
{
  ResponseMetadata: { RequestId: 'f396cad7-0756-43ae-8649-382d7aefe13f' },
  Credentials: {
    AccessKeyId: 'ASIA6KUFAVPUZV4F67HU',
    SecretAccessKey: 'isjnYSKF5m10EM68/Zft5TMGesqLBGEN2LVTTXdF',
    SessionToken: 'FwoGZXIvYXdzEH0aDIyJ+y29a+/7PT0BsCKxAcVKneYMeOFTkhx8SiUZkPcsqed5cC5JbT4bNPx/6qGyPS3PVeTyajMdxikP2muAYidsGrkvYAip94OM57nRZUueADEahRnjDts7c04TNYqpRhXuDcBJmTn+D/RHQY0fIAPYBw7kw2FlnFv6bVR1D0XD3DJewIeZMDgzVZWclgF2bmAkNudCW8FZ/f8lyTcoUNBfcveKVRmSUZbkOflYqf+yFkwj2CzElK4nwCBy30u1+yjy0+uFBjItzemlPRl0x4yWMrxBvRkPaSkg7peAnb6eo2j/r9QO4Qq2LLtzKN1VBXcT4BIG',
    Expiration: 2021-06-05T12:55:22.000Z
  },
  AssumedRoleUser: {
    AssumedRoleId: 'AROA6KUFAVPUZTMI6DMFH:1622862319468',
    Arn: 'arn:aws:sts::<スイッチロール先のAWSアカウントID>:assumed-role/non-97/1622862319468'
  }
}
ec2.describeInstances
ec2.describeInstances
{
  Reservations: [
    {
      Groups: [],
      Instances: [Array],
      OwnerId: '<スイッチロール先のAWSアカウントID>',
      ReservationId: 'r-0df0a7c8ca33ec359'
    },
    {
      Groups: [],
      Instances: [Array],
      OwnerId: '<スイッチロール先のAWSアカウントID>',
      ReservationId: 'r-0d53d5e6533a66ad4'
    }
  ]
}

それぞれ意図した通りの動作をしており、成功パターンのみ正常にEC2インスタンスの一覧をできていますね。めでたしめでたし。

好きな言語で快適にAWSを操作したい

AssumeRoleのために頻繁に環境変数の差し替えをするとミスも発生すると思ったので、今回スクリプトを作成してみました。
ぜひ、このスクリプトを利用して、快適なAWSライフを楽しんでください!!

このブログが誰かの助けになれば幸いです。

以上、東京オフィスの のん(@non____97)でした!