ALB + CognitoでGoogleアカウント認証をかける

この記事はクラスメソッドさんの記事『[Amazon CognitoユーザープールLambdaトリガーでALB認証のメールアドレスを制限する | Developers.IO](https://dev.classmethod.jp/articles/validate-email-on-alb-authentication/)]』の手順をちょっと（うまくいかないところを試行錯誤しつつ）詳しく書いたものです。

【2021/11/18追記】
認証が通った後、EC2側でメールアドレスや氏名を取得する方法についてコメント欄に追記しました。

# 何ができるか
**認証機能など何もない**WebアプリがEC2で動いているとします。このEC2を

* ALBの下にぶら下げる
* Cognitoの仕組みを使い、**特定のGoogleアカウントでログインしているときだけ**アクセスできるようにする

という仕組みをサーバーレス（AWS ALB + Amazon Cognito + Google Cloud Platform + AWS Lambda）で構築します。

# 前置き：ALB + Cognitoの何が混乱を招くか
Amazon Cognitoは素晴らしいサービスなのですが、「ユーザープール」と「IDプール」があり、初歩的なこと[^1]であればどちらを使っても似たようなことができるので話がとっちらかる傾向にあります。
[^1]: ドメインを絞ったGoogleアカウント認証とか

また、さらにややこしいことにGoogleはOpenIDプロバイダでもあり、Cognitoを使わなくてもALBのOIDC認証でGoogleアカウント認証ができてしまったりします（G Suiteを利用している場合、組織内アカウントのみを認証対象とすることでCognitoを使わずに済ませることも可能です）。この記事では

* ALBのOIDC認証機能は**使わない**
* Cognitoのユーザープールを**使う**
* CognitoのIDプールは**使わない**

ということを明確にしておきます。

# やっていく
実際に手を動かして進めていきましょう。

## Google Cloud Platformで新規プロジェクト作成
[Google Cloud Platform](https://console.cloud.google.com/)のコンソールにログインし、適当な名前でプロジェクトを作成します。

<details><summary>スクリーンショット</summary><div>
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/618258/888d571b-8fad-a83b-feb1-44dc1303c1ad.png)
</div></details>

### OAuth同意画面作成
【ナビゲーションメニュー > APIとサービス > OAuth同意画面】と進み、OAuth同意画面を作成します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/618258/f6aefeb9-e128-cebb-68da-461a4bab23f5.png)

G Suiteを利用している場合、ここで「内部」を選択することができます[^2]。「内部」を選ぶと、組織外部のGoogleアカウントはこのプロジェクトで認証をパスすることはできなくなります。これはつまり何を間違えても外部の人が認証を通ってしまうことがあり得ないということを意味しますので、特に必要がなければ「内部」の方が安全です。
[^2]: 逆に言うと、G Suiteを使っていない人は（表示されているのに）「内部」を選ぶことはできません。すっぱい葡萄みたいなオプションですね……
　
<details><summary>スクリーンショット</summary><div>
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/618258/a8be68f8-a6c9-807b-3840-012714032535.png)
</div></details>

見ての通り、必要なスコープはデフォルトで付いてくるので特に追加する必要はありません。とりあえず名前だけ入力して保存します。

#### ここで一応：スコープってなんだっけ
OAuthを使ったシステムでは、ユーザーがアプリケーションへ直接パスワード等を送るのではなく、「アプリケーションがこんな権限（＝**スコープ**）を要求してるけどどうする？」と**Googleのドメイン上で**認証画面が表示されるというのがキモでした。スコープを増やせばGmailを送信したりGoogle Driveへアクセスしたりできるようになるわけですが、ここにある `email profile openid` は無条件でくっついてくる最低限のスコープだということになります。

### OAuth 2.0クライアントID作成
続いて【認証画面】へ移動し、「認証情報を作成」→「OAuthクライアントID」と進んで「作成」をクリックします。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/618258/1ce0dbf0-afbd-4093-1c1e-43d8fbc6e8fd.png)

「アプリケーションの種類」は「ウェブアプリケーション」を選択して「作成」ボタンを押します。

<details><summary>スクリーンショット</summary><div>
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/618258/7d709449-af94-0462-8249-c60c9fcb6714.png)

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/618258/c1603ec9-083c-4c04-6976-a418e91f07ad.png)
</div></details>

OAuth 2.0クライアントIDが作成され、ポップアップでクライアントIDとクライアントシークレットが表示されます。この値をメモっておきます。

なお、この値はいつでも【認証情報 > OAuth 2.0クライアントID】を開けば参照できますからポップアップは閉じてしまっても大丈夫です。

<details><summary>スクリーンショット</summary><div>
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/618258/33ec1378-6e06-8ebf-7f5e-4e14a961b5b9.png)
</div></details>

## ALBの準備（前半）
こちらについては軽く触れるだけにします。

1. ACMで適当な証明書を用意しておく
2. インターネット向けのALBを作る
3. HTTPSでアクセスを受け付け、証明書を割り当てる
4. ターゲットグループはとりあえず空で作る
5. Route 53などでALBに `hello-cognito.example.com` というような名前を割り当てる
6. `https://ALBのFQDN` でALBにアクセスができるようになればOK
7. リスナーのルール編集画面を開いておく

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/618258/adfa3e4a-94de-90ed-3978-95920fbf4def.png)

この画面は後で触ります。

## Cognitoの準備
### ユーザープールを作成する
新たにユーザープールを作成します。適当に名前を決めて、「デフォルトを確認する」→「プールを作成する」でいいです。

<details><summary>スクリーンショット</summary><div>
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/618258/8377a219-cb43-e6bb-a172-eb70cfbebb78.png)
</div></details>

### アプリクライアント作成
続けてアプリクライアントを作成します。

* トークンの有効期限を適当な値（デフォルト値に設定）
* 不要な「SRP (セキュアリモートパスワード) プロトコルベースの認証を有効にする」のチェックを外す

これでアプリクライアントIDが作成されます。アプリクライアントIDは後でALBのリスナーに設定します。

<details><summary>スクリーンショット</summary><div>
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/618258/023c4d14-14f8-1b82-9f75-76a7dba19526.png)
</div></details>

### ドメイン名決定
Amazon Cognitoドメインを決めます（この操作により新たに取得されます）。
ここで決めた `https://YOUR-DOMAIN-NAME.auth.ap-northeast-1.amazoncognito.com` は後で使うのでメモっておきます。

<details><summary>スクリーンショット</summary><div>
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/618258/d680482d-638a-a211-d10f-10f213d61884.png)
</div></details>

### Googleとの紐付け
【フェデレーション > IDプロバイダー】を開き、「アプリID」と「アプリシークレット」に先ほどメモっておいた値を入力します。承認スコープは `email profile openid` として「Googleの更新」をクリックします。

<details><summary>スクリーンショット</summary><div>
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/618258/2cb48704-fd1a-a0e7-da21-ef4c1ac6d20e.png)
</div></details>

### 属性マッピング変更
【フェデレーション > 属性マッピング】を開き、「Google」タブからemailのマッピングを行って「変更の保存」をクリックします。

<details><summary>スクリーンショット</summary><div>
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/618258/6c4ce472-4bfd-a26b-08b2-b6fa3f42bd4d.png)
</div></details>

### アプリクライアント設定
アプリクライアントの設定からスクリーンショットの通りに各種設定を行います。
コールバックURLは `https://ELBに割り当てたFQDN/oauth2/idpresponse` になります。
（※このパスはALBが横取りして認証に使うため、EC2が `/oauth2/idpresponse` というリクエストを受け取ることはありません）

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/618258/0930fb1d-e623-6485-b899-f5d20858bbd0.png)


保存すると「ホストされたUI」リンクが有効になりますが、こちらをクリックする前にGoogle側の設定を済ませます。

### Google側の設定
Googleの設定画面に戻り、

* 承認済みのJavaScript生成元:
  * `https://ELBに割り当てたFQDN`
* 承認済みのリダイレクトURI:
  * 「ドメイン名」で決めた値 + `/oauth2/idpresponse`

を入力します。ここは一字一句違ってもエラーになるので気をつけましょう。

<details><summary>スクリーンショット</summary><div>
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/618258/ba493e76-f960-7a09-2805-90f34a08661a.png)
</div></details>

### Cognitoで動作確認
Cognitoの画面に戻り、「ホストされたUIを起動」リンクをクリックします。「Continue with Google」画面が出てきてGoogleログインが促されましたか？
最終的に `https://ALBのFQDN/oauth2/idpresponse?code=...` へリダイレクトされれば問題ありません。

<details><summary>スクリーンショット</summary><div>
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/618258/c341deee-d196-a07f-61af-e50282dc4318.png)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/618258/88a2b96a-bd4d-c923-2cb3-2b102870d44d.png)
</div></details>

### ALBのルール設定
ALBのリスナーからルール設定画面に戻り、ルールを設定します。「詳細設定」の中の「スコープ」は全部揃えるということで[^3] `email profile openid` にしておきます。
[^3]: 後述のトリガーLambda関数に渡されるパラメーターの形が変わりますが、Googleの場合はemailだけでもopenidだけでもメールアドレスが渡されてきます

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/618258/2de9a49f-07fd-8c28-66da-3bcad0e86722.png)


### 動作確認
いよいよ `https://ALBのFQDN` にアクセスしてみます。
Google認証が求められた上でEC2にアクセスできれば何もかもがうまく行ったということになります。
同時にHTTPS化もできてうれしいですね。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/618258/7fa53f22-a7b7-95cf-14e0-02df5983a173.png)

## ログインできるGoogleアカウントを決める
これでGoogleアカウント認証はできたのですが、今のままではどんなアカウントでも認証が通ってしまうため、事実上認証はないに等しい状態です。Lambda関数でカスタムのチェックを行いましょう。

### Lambda関数を作る
これから作るLambda関数がやるべきことはたった2つです。（したがってIAM Roleも最低限の権限で良いです）

1. 引数のオブジェクトからメールアドレスを取り出す
2. 問題ないなら引数をそのまま返す。認証を拒否するなら例外をスローして異常終了させる

リスナーのスコープとして `email profile openid` を指定した場合、「[サインアップ前](https://docs.aws.amazon.com/cognito/latest/developerguide/user-pool-lambda-pre-sign-up.html)」トリガーLambda関数にはこんなオブジェクトが渡されます。

```json
{
  "version": "1",
  "region": "ap-northeast-1",
  "userPoolId": "ap-northeast-1_wycr1qYwL",
  "userName": "google_999999999999999999999",
  "callerContext": {
    "awsSdkVersion": "aws-sdk-unknown-unknown",
    "clientId": "1iaca7vca5rl5dgpqc7ibd7em5"
  },
  "triggerSource": "PreSignUp_ExternalProvider",
  "request": {
    "userAttributes": {
      "cognito:email_alias": "",
      "cognito:phone_number_alias": "",
      "email": "itsumo.itsumo.nemui@gmail.com"
    },
    "validationData": {}
  },
  "response": {
    "autoConfirmUser": false,
    "autoVerifyEmail": false,
    "autoVerifyPhone": false
  }
}
```

この中からメールアドレスを取り出して、ドメインなり何なりで判定を行えばいいだけです。Node.jsだとどうもうまく行かなかったのでPython 3.7で書きました。

```python
def lambda_handler(event, context):
    print(event)
    email = event["request"]["userAttributes"]["email"]
    if email == "itsumo.itsumo.nemui@gmail.com":
        print("It's me! OK! OK!")
        return event
    raise Exception("bye-bye!")
```

これをトリガーに設定すれば終わりです。なお、トリガーを設定してから実際にトリガーが呼ばれるようになるまでには若干のタイムラグがあるようです。

<details><summary>スクリーンショット</summary><div>
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/618258/f555973a-ec3e-8f7d-b303-c0c3d0fd12e8.png)
</div></details>