なるべく手間をかけずにサーバーレスでCognitoユーザーがAWSマネジメントコンソールへログインできるようにする（ログイン画面も自作しないパターン）

# 経緯
[前回の記事](https://qiita.com/yusuke-takagi/items/9e914c43cc4b80055fb7)でCognitoユーザーがAWSマネジメントコンソールにログインできるようにしましたが、ログイン画面さえも自作しないでできることが分かったので書き残しておきます。

具体的にはCognitoユーザープールのホストされたUIを使います。
ホストされたUIでは不満がありログイン画面のデザインや動きを自作したい場合は[前回の記事](https://qiita.com/yusuke-takagi/items/9e914c43cc4b80055fb7)のパターンやウェブアプリを自作すると良いでしょう。

# 前提条件
- ユーザーにAWSマネジメントコンソールを使わせたい
- ユーザーはCLIでなく、ブラウザ（ログイン画面）でログインさせたい
- 事情があってAWS SSOに連携するIdPを使えない、使わない
- ログイン画面のためだけにウェブアプリを作りたくない
- サーバーの運用はなるべくしたくない
- IAMユーザーは発行したくない
- 運用チームがCognitoユーザープールでメンテナンスできる程度のユーザー規模

# おおまかな流れ
1. CognitoユーザープールとIDプールを作る
1. マネジメントコンソールへサインインするためのLambdaとAPI Gatewayを作る

## 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ユーザープールのアプリケーションクライアントも作成します。認証フローはデフォルトでチェックされている`ALLOW_REFRESH_TOKEN_AUTH`のみで良いです。

ホストされたUIのIDプロバイダーはCognitoユーザープール、OAuth付与タイプは`認証コード付与`、OpenID Connectスコープはopenidのみとします。
コールバックURLには、後で作るREST APIのURLを設定しますがいったん適当なダミー（`https://www.example.com`とか）を入力しておきます。

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`でログインした時は営業権限でコストエクスプローラーにアクセスできるというような制御ができます。

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

## LambdaとAPI Gatewayを作る
ここでCognitoユーザープールのホストされたUIからコールバックされるREST APIを作ります。

### Lambdaを作る
まずはLambdaを作ります。
参考にしたのはこちらです。
- [LambdaでCognito認証をやってみた【サーバレス】](https://blog.denet.co.jp/lambdacognito/)
- [【AWS】Amplifyを使わずに最小構成でCognitoログイン及びクライアント認証を実装する](https://zenn.dev/tentel/articles/7a27bbee1cca8b)

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

exports.handler = (event, context, callback) => {
    console.log(event);

    const authorizationCode = event.params.code;

    const region = 'ap-northeast-1';
    const userPoolId = 'ap-northeast-1_XXXXXX';
    const clientId = 'XXXXXXXXXXXXXXXX';
    const identityPoolId = 'ap-northeast-1:XXXXXXXXXXXXXXXXXX';
    const tokenEndpoint = 'https://XXXXXXXXXXXX.auth.ap-northeast-1.amazoncognito.com/oauth2/token';
    const redirectUri = 'https://XXXXXXXXXXXX.execute-api.ap-northeast-1.amazonaws.com/XXXX/XXXXXXXX';


    const tokenParameters = new URLSearchParams({
        grant_type: 'authorization_code',
        client_id: clientId,
        scope: 'openid',
        redirect_uri: redirectUri,
        code: authorizationCode
    }).toString();

    const options = {
        method: 'POST',
        cache: 'no-cache',
        headers: {
          'Content-Type': 'application/x-www-form-urlencoded',
        }
    };
    const request = https.request(tokenEndpoint, options, (response) => {
        console.log(`/oauth2/token: ${response.statusCode}, ${response.statusMessage}`);

        response.on('data', (data) => {
            const parsedData = JSON.parse(data);
            const logins = {};
            logins[`cognito-idp.${region}.amazonaws.com/${userPoolId}`] = parsedData.id_token;
            AWS.config.credentials = new AWS.CognitoIdentityCredentials({
                region: region,
                IdentityPoolId: identityPoolId,
                Logins: logins
            });
        
            AWS.config.credentials.get((err) => {
                console.log('getCognitoCredentials: 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) => {
                    console.log(`getSigninToken: status=${response.statusCode}, ${response.statusMessage}`);
                    
                    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('getSigninToken on error: err=');
                    console.log(err);
                });
            });
        });
    });
    request.write(tokenParameters);
    request.end();
};
```

### 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/)

### アプリクライアントのコールバックURLとLambda関数のredirectUriを書き直す

API Gatewayを作ってREST APIのURLが生成されたら、先に作っておいたCognitoユーザープールのアプリクライアントのコールバックURLと、Lambda関数のredirectUriに設定します。

# 使ってみる

アプリクライアントのホストされたUIをブラウザで表示して、ログインしてみましょう。
以上で仕組みはできました。
お疲れ様でした！