なるべく手間をかけず(Amplifyも使わず)にサーバーレスでCognitoユーザーがAWSマネジメントコンソールへログインできるようにする

# 追記
この記事を書いた後、さらにログイン画面（HTMLをS3でホスティング）の自作も不要なことが分かったため、以下の記事を書きました。
CognitoでホストされたUIが許容できる場合はさらに簡単に実現できます。
https://qiita.com/yusuke-takagi/items/37a51b453673b77bda23


# 経緯
タイトルの通りで、私の関わっているサービスで最近わりと切実に実現する必要があったのですが、ググってみてもそのものズバリの情報がなかなか見当たらず、調査にけっこう時間がかかってしまったので他の方々や後の自分が迷いなく実現できるように書き残しておきます。

もっと良いやり方があるよという方は教えてください。

# 前提条件
- ユーザーにAWSマネジメントコンソールを使わせたい
- ユーザーはCLIでなく、ブラウザ（ログイン画面）でログインさせたい
- 事情があってAWS SSOに連携するIdPを使えない、使わない
- ログイン画面のためだけにウェブアプリを作りたくない
- サーバーの運用はなるべくしたくない
- IAMユーザーは発行したくない（セキュリティリスクの問題です。IAMユーザーを発行しても良いという場合はそれで解決です。この後を読む必要はありません。お疲れ様でした！）
- 運用チームがCognitoユーザープールでメンテナンスできる程度のユーザー規模（うちの場合は予想2桁程度なので余裕）

Cognito ログイン画面 などでググるとAmplifyが良くヒットするのですが、Amplifyを使わなくても実現できることが分かったので使いません。

# おおまかな流れ
1. CognitoユーザープールとIDプールを作る
1. マネジメントコンソールへサインインするためのLambdaとAPI Gatewayを作る
1. S3バケットでログイン画面のHTMLファイルをホスティングする

## CognitoユーザープールとIDプールを作る

[こちらの記事を参考にして](https://dev.classmethod.jp/articles/get-aws-temporary-security-credentials-with-cognito-id-pool-by-aws-cli/)、CognitoユーザープールとIDプールを作ります。

私の場合、CognitoユーザープールのサインインはEメールのみとしました。こうすると1つのメールアドレスに紐づけられるIAMロールは1種類のみになりますが、1人の人に複数種類のIAMロールを持たせたい時は複数のメールアドレスを使い分けてもらうつもりです。（例えば`yamada.taro+developer@example.com`と`yamada.taro+sales@example.com`のように）

Cognitoユーザープールのアプリケーションクライアントも作成します。認証フローは`ADMIN_USER_PASSWORD_AUTH`を使っているので、`ALLOW_ADMIN_USER_PASSWORD_AUTH`にチェックを付けます。

IDプールの認証プロバイダーにCognitoを選択し、先ほど作成したユーザープールのユーザープールIDとアプリクライアントIDを設定します。
認証されたロールの選択は`トークンからロールを選択する`としておきます。

ユーザープールの側でグループを作成し、それぞれのグループに別々のIAMロールを付けておきます。
（例えば`developer`グループと`sales`グループのように）
グループに付けるIAMロールは、IDプールを作成する時にデフォルトで作成された「認証されたロール」`Cognito_xxxxAuth_Role`をコピーして必要な権限を追加するのがおすすめです。
一から自分で作ろうとすると、必要な権限が足りなくてログインができないなどで慌てることになります。私はそうなりました。

ユーザープールに登録されたユーザーをグループに追加しておきます。
例えば`yamada.taro+developer@example.com`を`developer`グループに、`yamada.taro+sales@example.com`を`sales`グループに追加します）

こうしておくと`yamada.taro+developer@example.com`でログインした時は開発者権限でS3バケットにアクセスでき、`yamada.taro+sales@example.com`でログインした時は営業権限でコストエクスプローラーにアクセスできるというような制御ができます。

サービスのユーザーが増えるたびに、そのユーザーに権限を割り当てる（適切なグループに追加する）作業が発生します。

### CLIでAWSマネジメントコンソールにログインできた。でもブラウザでは？

[こちらの記事を参考にして](https://dev.classmethod.jp/articles/aws-management-console-login-with-cognito-user/)、CLIでAWSマネジメントコンソールにログインすることはできました。このCLIをHTML + JavaScriptの静的コンテンツにしてS3バケットに置けば、ログイン画面ができるんじゃないかと思いましたが、結論はNGでした。

#### CORSが許可されない

AWSマネジメントコンソールにログインするためにサインインURLにリダイレクトする必要がありますが、その前にサインイントークンを取得する必要があります。サインイントークンを取得するにはリクエストを発行し、そのレスポンスに含まれるサインイントークンを元にしてサインインURLを組み立てる必要があります。つまり、サインイントークン取得リクエストは非同期/JavaScriptで行う必要があります。

ところが、リクエスト先のAWSマネジメントコンソール(https://signin.aws.amazon.com/federation )はCORSを許可してくれません。S3バケットに置いたHTML+JavaScriptからサインイントークン取得リクエストを発行すると以下のようなエラーログが出ます。
Origin https://xxxxxxxxxx.s3.ap-northeast-1.amazonaws.com is not allowed by Access-Control-Allow-Origin.

リクエスト元のS3バケットのCORSで全てのオリジン(*)からのGETメソッドを許可しても効果がありません。リクエスト先のAWSマネジメントコンソール側でAccess-Control-Allow-Originを許可する必要がありますが、その方法は分かりませんでした（分かる方、いらっしゃいましたら教えてください）

CORS制限はブラウザの挙動で回避することは一応可能です。
[Cross Domain - CORS](https://chrome.google.com/webstore/detail/cross-domain-cors/mjhpgnbimicffchbodmgfnemoghjakai/related?hl=ja)というChrome拡張機能をインストールすればブラウザはCORS制限を無視してAWSマネジメントコンソールにサインインを強行してくれます。類似の拡張機能で[CORS Unblock](https://chrome.google.com/webstore/detail/cors-unblock/lfhmikememgdcahcdlaciloancbhjino?hl=ja)というものもありますが、こちらはAWSマネジメントコンソールへのログインはできませんでした。
しかし、必ずしもPCやネットに詳しくない一般ユーザーにCORS制限解除を強制するリスクは避けたいです。

## LambdaとAPI Gatewayを作る

ここまでで、HTMLとJavaScriptのみ、クライアント側のみではAWSマネジメントコンソールへのログイン画面を作るのが難しいことが分かりました。
サーバー側でのサインイン処理が必要です。
サーバーの運用はなるべくしたくないのでサーバーレスのLambdaとAPI Gatewayで作ります。

### Lambdaを作る
まずはLambdaを作ります。
参考にしたのはこちらです。
[LambdaでCognito認証をやってみた【サーバレス】](https://blog.denet.co.jp/lambdacognito/)

Lambda > 関数を作成 > Node.js 16.x, arm64 を選びました。
このREST APIに対して`username`, `password`のパラメータが送られてくる前提です。
以下のコード内の`region`, `userPoolId`, `clientId`, `identityPoolId`は利用するリージョン、CognitoユーザープールID、アプリクライアントID、IDプールのIDに置き換えてください。
リクエストを発行するたびにネストが深くなっていてダサいですが時間のある方はFutureで書き直すなどしてみてください。
```index.js
const AWS = require('aws-sdk');
const https = require('https');

exports.handler = (event, context, callback) => {
    const username = event.params.username;
    const password = event.params.password;
    const region = 'ap-northeast-1';
    const userPoolId = 'ap-northeast-1_XXXXXX';
    const clientId = 'XXXXXXXXXXXX';
    const identityPoolId = 'ap-northeast-1:XXXXXXXXXX';

    const cognito = new AWS.CognitoIdentityServiceProvider();
    cognito.adminInitiateAuth({
        UserPoolId: userPoolId,
        ClientId: clientId,
        AuthFlow: 'ADMIN_USER_PASSWORD_AUTH',
        AuthParameters: {
            USERNAME: username,
            PASSWORD: password
        }
    }, (err, data) => {
        console.log(err);

        const logins = {};
        logins[`cognito-idp.${region}.amazonaws.com/${userPoolId}`] = data.AuthenticationResult.IdToken;
        AWS.config.credentials = new AWS.CognitoIdentityCredentials({
            region: region,
            IdentityPoolId: identityPoolId,
            Logins: logins
        });
    
        AWS.config.credentials.get((err) => {
            console.log(err);
            const sessionId = AWS.config.credentials.data.Credentials.AccessKeyId;
            const sessionKey = AWS.config.credentials.data.Credentials.SecretKey;
            const sessionToken = AWS.config.credentials.data.Credentials.SessionToken;
            const sessionJson = encodeURIComponent(`{"sessionId":"${sessionId}","sessionKey":"${sessionKey}","sessionToken":"${sessionToken}"}`);
            const getSigninTokenUrl = `https://signin.aws.amazon.com/federation?Action=getSigninToken&SessionType=json&Session=${sessionJson}`;
            https.get(getSigninTokenUrl, (response) => {
                response.on('data', (data) => {
                    const parsedData = JSON.parse(data);
                    const signinToken = parsedData.SigninToken;
                    const issuer = encodeURIComponent('https://example.com');
                    const destination = encodeURIComponent('https://console.aws.amazon.com');
                    const signinUrl = `https://signin.aws.amazon.com/federation?Action=login&Issuer=${issuer}&Destination=${destination}&SigninToken=${signinToken}`;
                    context.succeed({"Location": signinUrl});
                });
            }).on('error', (err) => {
                console.log(err);
            });
        });
    });
};
```

このLambdaに以下の実行権限を付ける必要があります。Lambdaの実行ロールに以下のポリシーを追加します。
Resourceは利用するCognitoユーザープールのARNに置き換えてください。
```
{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Action": "cognito-idp:AdminInitiateAuth",
            "Resource": "arn:aws:cognito-idp:ap-northeast-1:XXXXXXXXXX:userpool/ap-northeast-1_XXXXXX"
        }
    ]
}
```

バッドノウハウですが、このポリシーはCognitoユーザープールの「認証されたロール」`Cognito_xxxxAuth_Role`や、そこからコピーして作ったユーザーグループのロールにも追加しておくと良いです。ユーザーが一旦`yamada.taro+developer@example.com`でログインし、ログアウト操作をせずにログイン画面で`yamada.taro+sales@example.com`でログインしようとしたとします。この時AdminInitiateAuthはLambdaの実行ロールでなく`yamada.taro+developer@example.com`のユーザーグループのロールで実行されます。
このため、ユーザーグループのロールにもAdminInitiateAuth権限を付けておくとユーザーの利便性が高くなります。そうしない場合はユーザーはログインし直す前に必ずログアウト操作が必要になります。

### API Gatewayを作る

次にこちらを参考にして、API GatewayがLambdaで返したサインインURLにリダイレクトできるように設定します。
[API Gateway + Lambdaで任意のURLにリダイレクトする方法](https://qiita.com/gp333/items/b79343af3ef220a7b472)

うちのサービスを使うユーザーは社員のみなので、こちらを参考にして会社のプロキシサーバーのIPアドレス以外からREST APIにアクセスできないように設定しておきます。
[特定の IP アドレスのみが API Gateway REST API にアクセスすることを許可するにはどうすればよいですか?](https://aws.amazon.com/jp/premiumsupport/knowledge-center/api-gateway-resource-policy-access/)

## S3バケットでログイン画面をホスティングする

最後にクライアント側のHTMLファイルを作ります。
`username`（私の場合はEメールアドレス）と`password`をAPI Gatewayに送るフォームです。
フォームのアクションは利用するREST APIのアドレスに置き換えてください。
```index.html
<!DOCTYPE html>
<html>
<head>
  <meta charset="UTF-8"></meta>
  <title>Sign in</title>
</head>
<body>
  <h1>Sign in</h1>
  <form action="https://xxxxxxxxx.execute-api.ap-northeast-1.amazonaws.com/xxxx/xxxx">
    <span>Email</span>
    <input type="text" name="username" size="40">
    <br/>
    <span>Password</span>
    <input type="password" name="password" size="40">
    <br/><br/>
    <input type="submit" value="Sign in">
  </form>
</body>
</html>
```

このHTMLファイルをS3バケットに置き、S3バケットをホスティングします。
具体的にはブロックパブリックアクセスをオフにします。
ただし、うちのサービスを使うユーザーは社員のみなので、こちらを参考にして会社のプロキシサーバーのIPアドレス以外からS3バケットにアクセスできないように設定しておきます。
[特定の VPC エンドポイントまたは IP アドレスを使用して Amazon S3 バケットへのアクセスを制限するにはどうすればよいですか?](https://aws.amazon.com/jp/premiumsupport/knowledge-center/block-s3-traffic-vpc-ip/)

HTML+JavaScriptのみでログイン画面を作ろうとした時と比べて、静的コンテンツに露出している秘密情報がREST APIのURLのみになっているのもセキュリティ強度が上がっていて良さそうな気がします。

ユーザー規模も小さいのでCloudFrontなんかもかまさず直接S3バケットを覗きにきてもらうことにします。

# 使ってみる

`yamada.taro+developer@example.com`や`yamada.taro+sales@example.com`でログインして、権限が変わることを確認してみましょう。
以上で仕組みはできました。
お疲れ様でした！