RusotoでAssumeRole + MFAを処置しつつ有効期限中は繰り返しMFAを求められないようにしてみる

RusotoでStsAssumeRoleSessionCredentialsProviderを使いAssumeRolesしつつMFAを行おうとして、古いトークンを使って何度も再認証しようとする状態にハマっていました。とりあえず想定した動作に至れたので書いてみました。
2021.03.03

はじめに

担当業務の一環にAthenaを使って計算を行うタスクがしばしば発生していました。ただ、該当タスクを特定メンバーのみに限定する必要が特になく、全員用にロールを発行してタスク処理待ちを減らそうということになりました。

一つネックだったのは、メンバー異動や追加によるロール編集をどうするかという点です。そこが特定メンバー依存になるようでは今度はロール編集が処理待ちになります。手順を書くことを考えつつもコマンドを一回叩くだけでロールが更新されると楽かもしれないと思い、Rust + Rusotoでロール更新コマンドを作成に取り掛かってみました。

コミットログで管理しやすいようにメンバーのロール名を設定ファイルから取得するようにしたり、Clapから必要な引数をとったりと必要な動作を整えたものの、一番手間取ったのはSwitchRole + MFAでした。解決に至ったのはIntellijのヘルプポップアップテキストです。具体的なコード例を交えて書いてみます。

Rusotoにおける認証

Rusotoに関してドキュメント化されているものはとても少ない状態です。基本は以下のドキュメントを見ることになります。

SwitchRole + MFAにてassume_roleした後にIamClientを作成する

StsAssumeRoleSessionCredentialsProviderを使った認証については、過去に以下のブログで取り上げられています。

今回はMFAが必要になるため、mfa_serialを入れます。

let provider = StsAssumeRoleSessionCredentialsProvider::new(
    sts,
    "arn:aws:iam::[ACCOUNT_ID]:role/[ROLE_NAME]",
    "default".to_owned(),
    None,
    None,
    None,
    "arn:aws:iam::[ACCOUNT_ID]:mfa/[NAME]
);

MFAについてはstdin経由で引数を取る形にします。

    let result = provider.assume_role().await;
    if result.is_err() {
        let mut s = String::new();
        println!("Please enter mfa code: ");
        let _ = stdout().flush();
        stdin()
            .read_line(&mut s)
            .expect("Did not enter a correct string");
        if let Some('\n') = s.chars().next_back() {
            s.pop();
        }
        if let Some('\r') = s.chars().next_back() {
            s.pop();
        }
        provider.clear_mfa_code();
        provider.set_mfa_code(s.to_string());
    }
    return IamClient::new_with(HttpClient::new().unwrap(), provider, Region::UsEast1);

これで手続きとしてはできています。が、この状態で各種リクエストを実施していくと、途中で

StsProvider get_session_token error: Unknown(BufferedHttpResponse {status: 403, body: "<ErrorResponse xmlns=\"https://sts.amazonaws.com/doc/2011-06-15/\">\n  <Error>\n...

とでて処理が進まなくなります。理由はシンプルで、StsAssumeRoleSessionCredentialsProviderに前回入力したワンタイムトークンを再利用して認証しようとするためです。ではその都度MFAを取得させるべきかといえば、リクエストが発生する毎にユーザに入力を促すことになり、とても非効率です。

StaticProviderを使った認証

一応ヒントはありました。気がつけるかどうかは別として。

provider.assume_role().await;と試しにコードを書き、assume_role()にカーソルをあわせてみましょう。

次に、ポップアップ上のAwsCredentialsをクリックします。

Anonymous Exampleに記載されているStaticProviderをはさみつつ、通しの処理を書いてみます。

use rusoto_core::{HttpClient};
use rusoto_credential::StaticProvider;
use rusoto_iam::{IamClient};
use rusoto_signature::region::Region;
use rusoto_sts::{StsAssumeRoleSessionCredentialsProvider, StsClient};
use chrono::Duration;

async fn get_iam_client() -> IamClient {
    let sts = StsClient::new(Region::UsEast1);
    let mut provider = StsAssumeRoleSessionCredentialsProvider::new(
        sts,
        "arn:aws:iam::[ACCOUNT_ID]:role/[ROLE_NAME]",
        "default".to_owned(),
        None,
        None,
        None,
        "arn:aws:iam::[ACCOUNT_ID]:mfa/[NAME]"
    );

    let result = provider.assume_role().await;
    if result.is_err() {
        let mut s = String::new();
        println!("Please enter mfa code: ");
        let _ = stdout().flush();
        stdin()
            .read_line(&mut s)
            .expect("Did not enter a correct string");
        if let Some('\n') = s.chars().next_back() {
            s.pop();
        }
        if let Some('\r') = s.chars().next_back() {
            s.pop();
        }
        provider.clear_mfa_code();
        provider.set_mfa_code(s.to_string());
    }
    let static_provider = StaticProvider::from(provider.assume_role().await.unwrap());
    return IamClient::new_with(HttpClient::new().unwrap(), static_provider, Region::UsEast1);
}

AssumeRoleを試し、エラーであればワンタイムトークンを受けつけます。再度AssumeRoleを行って発行されるAwsCredentialsをStaticProviderに渡して固定させます。セッション期限の1時間以内であればリクエストを何度でも問題なく送れる状態になりました。

あとがき

StaticProviderでAwsCredentialsを直接受け付けることに気がつくまで数日ハマり続けました。SwitchRoleについてはGitHubのドキュメントがありますが、StaticProviderについてはTOKENとSECRETを直接入力する例が多く、それ専用だと思い込んだためです。

SwitchRole + MFAでの実装を行う場合の参考になれば幸いです。

参考リンク