Amazon Cognito をバックエンドから Python で利用する

# はじめに

Amazon Cognito はサービスのユーザー情報・認証を管理するサービスである。 そして、その一般的な利用シナリオとしては以下のページで説明されている。

https://docs.aws.amazon.com/ja_jp/cognito/latest/developerguide/cognito-scenarios.html

しかし、ここで説明されている６つのシナリオは全てクライアント (App) から直接 Cognito を呼び出している。 ユーザー情報・認証の管理を行うサービスというのであれば、バックエンドサービスから認証部分だけを分離するという設計をして、以下の図右のようにバックエンドから直接 Cognito を使いたいという要望もあるはずである。 ここではこれを「バックエンドからCognitoを利用する」と呼ぶことにする。

![スライド1.PNG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/502582/d91b8d5a-525c-59e6-86a2-bf5975183102.png)

しかし、これは **一般的なシナリオには書かれていない。** ということは、Cognito をこのような要件では使えないのだろうか？

**答えは No であり、Cognito はこのようなケースで利用されることも想定している。** ただし、そのことはドキュメントのメインどころか、サブ機能として非常にこっそりと書かれている。

https://docs.aws.amazon.com/ja_jp/cognito/latest/developerguide/amazon-cognito-user-pools-authentication-flow.html

一般的なユースケースではないとはしているが、サービスの作りによってはバックエンドからCognitoを利用したいこともあるだろう。 そこで、本記事ではこの用途で Cognito を利用する方法についてを検討する。


# 本記事の題材となるプログラム

Github で今回検討する内容を公開している。 プログラムとしては200行強の短いものになっている。
以下、適時引用するが、全体を確認したい場合は以下のリポジトリを参照のこと。

https://github.com/t-kigi/cognito-backend-example


## Cognito UserPool の設定と作成

ユーザープールは Management Console でステップに従って順次作成していくことを想定している。
この時、今回検証したユーザープールでは以下の設定でユーザープールを作成した。 指定がない部分はコンソールのデフォルト値を利用している。

- エンドユーザーをどのようにサインインさせますか?: ユーザー名に☑を入れ、それ以外のラジオボタンは選択しない
- パスワード最低長は8文字。 大文字を必要とする、小文字を必要とするのみに☑。
- アプリクライアントを追加する
    - 名前: app-client
    - IDトークンの有効時間: 5分
    - (クライアントシークレットを生成するには☑をつけたままにする)
    - 認証フローの設定では "認証用の管理 API のユーザー名パスワード認証を有効にする (ALLOW_ADMIN_USER_PASSWORD_AUTH)" と "更新トークンベースの認証を有効にする (ALLOW_REFRESH_TOKEN_AUTH)" の２つに☑を入れて、それ以外は外す
    - 次のステップに進むを押す前に **アプリクライアントの作成** を押して、アプリクライアントを追加する

バックエンドでCognitoを利用する場合は、`cognito-idp` の `admin...` という API を使うことになるのだが、**これでログインを行う場合 `ALLOW_ADMIN_USER_PASSWORD_AUTH` を設定しておく必要がある。**
また、今回はクライアントシークレットを生成している。 フロントエンドからのログイン用途で Cognito を使う場合は [認証フローで必要な場合を除き、オプションはオフにします](https://docs.aws.amazon.com/ja_jp/cognito/latest/developerguide/cognito-user-pools-app-idp-settings.html) とあるため、オフにすることも多いが、今回の用途ではオンの場合を説明している。

それ以外の部分は検証用の簡易設定になっているため、自分の環境で利用したい場合はその環境に合わせた設定を行ってユーザープールを作成・設定すること。

## 設定込みの `app.py` を作成

リポジトリからコピーした `app-tpl.py` を `app.py` にコピーして、作成した Cognito UserPool の値を設定する。

```python
REGION = 'ap-northeast-1'                                                       
POOL_ID = '<input your cognito userpool id>'                                    
APP_CLIENT_ID = '<input your app client id in cognito userpool>'                
APP_CLIENT_SECRET = '<input your app client secret>'                            
PROFILE = '<input your local profile>'      
```

これを保存すれば準備はOK。


## サーバーアプリケーションの起動

`pipenv install` で必要なライブラリをインストールして、`pipenv run python app.py` で開発用サーバーを起動。

ここでは、ローカル環境に `pyenv` と `pipenv` がインストールされていることを前提としているため、それらがインストールされていない場合はインストールするか、代替策を使うこと。

サーバーの稼働後 http://localhost:8000 にアクセスすると、以下の画面が表示される。

![SnapCrab_NoName_2021-10-19_17-28-41_No-00.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/502582/a9e96705-7a24-3a76-2b62-5218058ba3c6.png)


## ユーザーの作成

画面内から「ユーザーの作成」ボタンを押して、ユーザーを作成する。　ユーザーの生成にはユーザー名とパスワードを入力している。

サーバー側では以下のコードでユーザーを生成している。 今回は初期パスワードを TemporaryPassword として入力しているが、これを入力しない場合は Cognito 側でパスワードを自動生成してくれる。

```python
# ユーザー作成
cognito.admin_create_user(
    UserPoolId=POOL_ID,
    Username=username,
    TemporaryPassword=password,
    MessageAction='SUPPRESS',   # RESEND にすればメールも送る
)
```

この API の実行後、Cognito UserPool のユーザー一覧を見ると、以下の様にユーザーが作成されていることが確認できる。 今回はユーザー名 `test` 、パスワード `Password` を入力してユーザーを生成した。

![SnapCrab_NoName_2021-10-19_17-33-5_No-00.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/502582/27567f93-1a4b-8a5f-d219-a8e69c7ac0c8.png)

今回の UserPool の設定では、入力したユーザー名 = Cognitoのユーザー名となっているが、Cognitoの設定次第では Cognito が生成した UUID が設定されることもある。

![SnapCrab_NoName_2021-10-19_17-35-9_No-00.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/502582/75fcff56-f382-2b46-8048-e18dc1d8eef2.png)


## ログイン

今作成したユーザーで http://localhost:8000 からログインすると、以下の様なページが表示される。 ログインに失敗すると例外を raise する。

![SnapCrab_NoName_2021-10-19_17-37-58_No-00.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/502582/e4ace393-556b-f485-ab8c-7dce1fba5dac.png)

ログインをするためのコードは以下のとおりである。

```python
cognito.admin_initiate_auth(
    UserPoolId=POOL_ID, ClientId=APP_CLIENT_ID,
    AuthFlow='ADMIN_USER_PASSWORD_AUTH',
    AuthParameters={
        'USERNAME': username,
        'PASSWORD': password,
        'SECRET_HASH': _secret_hash(username),
    }
)
```

クライアントシークレットを利用している場合、その値を利用したシークレットハッシュを生成して API に渡さなければならない。 算出方法は以下の通り。

```python
def _secret_hash(username) -> str:
    # see - https://aws.amazon.com/jp/premiumsupport/knowledge-center/cognito-unable-to-verify-secret-hash/  # noqa
    message = bytes(username + APP_CLIENT_ID, 'utf-8')
    key = bytes(APP_CLIENT_SECRET, 'utf-8')
    digest = hmac.new(key, message, digestmod=hashlib.sha256).digest()
    return base64.b64encode(digest).decode()

```

https://aws.amazon.com/jp/premiumsupport/knowledge-center/cognito-unable-to-verify-secret-hash/


ユーザーを生成した直後の場合、ユーザーの状態は `FORCE_CHANGE_PASSWORD` であり、パスワードのリセットを要求する状態になっている。 ここでは、ログインの直後に同様のパスワードでリセットした後、同じユーザー・パスワードで再度ログインする簡易的な作りにしているが、実際のアプリケーションではパスワードのリセットページへ飛ばすなどの処理をするのが良いだろう。

```python
if res.get('ChallengeName') == 'NEW_PASSWORD_REQUIRED':
    # この場合、Cognito が新しいパスワードを要求している状態なので
    # 状況次第でパスワードリセット画面に飛ばすなどの実装を行う
    # ここでは、このステータスは無いものとして強制的にリセットする
    cognito.admin_set_user_password(
        UserPoolId=POOL_ID,
        Username=username, Password=password,
        Permanent=True)
    res = _login(username, password)
```

## ログイン状態の検証

今回はログイン後得られた、Cognito のユーザー名、IDトークン、リフレッシュトークンを Cookie に設定するようにしている。 Cognito 上のユーザー情報は ID トークンに記載されているため、この JWT トークンを検証すれば、正常にログインした情報であるかを検証できる。

IDトークンの検証は `_verify_token` 関数で実現している。

```python
def _verify_token(cognito_userid: Optional[str],
                  id_token: Optional[str]) -> bool:
    """ Cognito User Pool の IdToken が有効か否かを返す """
    if not cognito_userid or not id_token:
        return False
    header = jwt.get_unverified_header(id_token)
    key_id = header['kid']
    alg = header['alg']
    keys = [k for k in jwks.get('keys', []) if k['kid'] == key_id]
    if len(keys) <= 0:
        return False
    public_key = RSAAlgorithm.from_jwk(json.dumps(keys[0]))

    try:
        payload = jwt.decode(
            id_token, public_key, algorithms=[alg], verify=True,
            options={'require_exp': True},
            audience=APP_CLIENT_ID, issuer=COGNITO_URL)
    except Exception:
        # 認証失敗 (エラーの種別を判断したいならここで処理)
        return False

    # sub クレームは、認証されたユーザーの固有識別子 (UUID)
    # ただし、UserPool の username と一致するとは限らない
    # そのため sub と cognito:username とも比較する
    return cognito_userid in [v for v in [
        payload.get('sub'), payload.get('cognito:username')
    ] if v]
```

やっていることを順に並べると以下の様になる。

- IDトークンの基礎情報 (利用している鍵・アルゴリズム) を取得
- Cognito UserPool ごとに設定される公開鍵情報 (jwks) を持ってきて、その内、IDトークンを暗号化した鍵に対応する公開鍵を取得
- その情報を使って JWTトークンをデコード＋内容の検証 (例: トークンが期限切れになっていないかなど)
- トークンの検証が正常に成功した場合、予期したユーザーのトークンであるかを判断

この手順については、以下の記事を参考にした。

https://yomon.hatenablog.com/entry/2020/07/pyjwt_validate


## IDトークンの検証と更新

ウェブサイト上にある「ログイン必須エリアへ」ボタンを押すと、ログイン必須エリア `/private/hello` を開く。 この時、以下の様な処理が行われる。

- Cookie上に有効な認証情報がない場合: `/` へと 302 リダイレクトされる (コンテンツを表示させない)
- Cookie上に有効な認証情報がある場合: `/private/hello` を表示する

Cognito の仕様から ID トークンは最大で1日しか有効期限がない。 それも、今回の設定では最短の5分に設定している。 しかし、IDトークンの有効期限が短いのは、一般的なユースケースであり、IDトークンが期限切れとなった場合はリフレッシュトークンを使って更新を行い、新たなIDトークンを取得する。
今回は Cookie にこれらのトークンを載せているので、IDトークンが有効期限切れになった場合は、自動的にIDトークンを更新するような仕組みにしたい。

今回は、Cookie上に有効な認証情報(リフレッシュトークン)があるが、IDトークンが期限切れとなっている場合、`/refresh` というアドレスに 307 Temporary Redirect を行い、ここで Cookie を設定、その後、同URLで `callback` 指定したURLに再び 307 で戻す、といった処理を実装することで、Cookie を自動的に更新するようにした。
リダイレクトを 307 で指定することで、GET のみならず、POST であれば再び同じパラメータによる POST を行わせることができるので、ブラウザを利用するユーザーから見ると、トークンを更新しているかどうかは気にならないレベルの処理となる。


具体的なコードは以下の通り。

```python
@app.get('/private/hello')
def private_hello():
    """ プライベートエリア """
    # バリデーション: 実際には decorator を作成して使うのが実践的
    username = request.cookies.get('username')
    id_token = request.cookies.get('id_token')
    if not _verify_token(username, id_token):
        return redirect('/refresh?callback=/private/hello', code=307)

    return render_template('private.html')


def refresh():
    """ 期限切れトークンのリフレッシュ """
    callback = request.args.get('callback')
    if not callback:
        raise ValueError('callback required')

    username = request.cookies.get('username')
    token = request.cookies.get('refresh_token')
    if not username or not token:
        # Cookie に入力がない場合
        # Cookie に入力がない場合 (ログインページなどに飛ばすのが良い)
        return redirect('/', code=302)

    try:
        # secret_hash で渡す username はログイン時はメールアドレスなどの
        # ログインが可能な情報でよかったが、リフレッシュの時は
        # Cognito UserPool 上で一意となる Username である必要がある
        res = cognito.admin_initiate_auth(
                UserPoolId=POOL_ID, ClientId=APP_CLIENT_ID,
                AuthFlow='REFRESH_TOKEN_AUTH',
                AuthParameters={
                    'REFRESH_TOKEN': token,
                    'SECRET_HASH': _secret_hash(username)
                }
            )
    except Exception as err:
        # トークンの更新に失敗
        # ログイン期限が切れたと判断してログインページにリダイレクトするなど
        raise err

    # IDトークンを更新して callback を呼び出す
    response = make_response(redirect(callback, code=307))
    response.set_cookie('id_token',
                        value=res['AuthenticationResult']['IdToken'])

    return response
```

IDトークンが有効期限内の場合、以下の通り１度目のアクセスで 200 が返る。

```
127.0.0.1 - - [19/Oct/2021 18:00:31] "GET /private/hello HTTP/1.1" 200 -
```

一方、アクセス時に有効期限外の場合、307 で `/refresh` に遷移し、トークンを更新する `Set-Cookie` を持ってくると同時に 307 で callback 先の `/private/hello` へと再遷移している。

```
127.0.0.1 - - [19/Oct/2021 18:00:58] "GET /private/hello HTTP/1.1" 307 -
127.0.0.1 - - [19/Oct/2021 18:00:59] "GET /refresh?callback=/private/hello HTTP/1.1" 307 -
127.0.0.1 - - [19/Oct/2021 18:00:59] "GET /private/hello HTTP/1.1" 200 -
```

わざわざ別の URL にリダイレクトするのは、CloudFront の Lambda@Edge 上でのトークン更新を意図したものになっているため、単なるバックエンドサーバーとして使う場合には過剰かもしれない。

ちなみに、コードのコメントにも書いてあるが、リフレッシュ時の `SECRET_HASH` の username なのだが、別設定の UserPool でメールアドレスでログインする設定にしている場合などはログイン時にはこのメールアドレスを渡せばよいのに、ログイン後は Cognito UserPool 上の username (UUID) と一致する必要があった。 そのため大分迷ったが、これは、以下の記事が参考になった。

https://qiita.com/nori3tsu/items/7fa72c297f93efd0b3bb


# ログアウト

これも Admin API を呼び出すだけ。 コードでは Cookie もクリアするコードも追加されている。

```python
@app.post("/logout")
def logout():
    """ ログアウトを実施 """
    username = request.cookies.get('username')
    if username:
        try:
            cognito.adminUserGlobalSignOut(
                UserPoolId=POOL_ID, Username=username)
        except Exception:
            pass

    # Cookie をクリア
    response = make_response(redirect('/', code=302))
    response.delete_cookie('username')
    response.delete_cookie('id_token')
    response.delete_cookie('refresh_token')

    return response
```

なお、Cognito からログアウトしても、ログイン中に発行したIDトークンは有効期限がくるまでは有効である。 そのため、流石に5分は短すぎて推奨されないが、IDトークンの有効期限は十分に小さくしておくべきである。

# Cognito UserPool の暗号鍵について

コード上で公開鍵をウェブ上から取得しているため、これはローテーションのために用意されているのかと思ったのだが、記事執筆時点ではローテーションの機能はないようだ。

https://forums.aws.amazon.com/thread.jspa?threadID=241570

そのため、キーローテーションは未実装 = わざわざリクエストして取得せずに固定組み込みでOKだが、システムに固定値で組み込む場合はこの jwks.json に変化がないかを監視するスクリプトを書いておいた方が良いかもしれない。


# 最後に

公式の紹介をなかなか見つけられなかっただけに苦労したが、公式ページを見つけてキーワードが分かれば比較的とんとん拍子に実装することができた。
Google でも検索ワードさえ分かればちゃんと情報が落ちていたので、できればこのユースケースに関して、わかりやすいところに説明を置いていてほしかった。


# 参考

https://note.kiriukun.com/entry/20191023-server-side-aws-cognito-user-pool-authentication-in-nodejs