🍣

Amazon Cognitoのカスタム認証を使って、外部で発行されたJWTで認証する

2023/03/03に公開

Amazon Cognitoのカスタム認証とは?

https://docs.aws.amazon.com/ja_jp/cognito/latest/developerguide/user-pool-lambda-challenge.html

CognitoはAWSが提供する認証・認可のサービスです。
Cognitoが標準で用意する認証フロー以外の方法で認証したいときに、独自の認証処理をLamdaで書くことができるのがカスタム認証になります。

なぜやってみたのか

以下のような話があったので、Amazon Cognitoのカスタム認証フローを使って、JWTを検証して認証する処理を試しに作ってみました。

  • あるシステムのサブシステムをAWS上に作りたい
  • 既存システムにログイン機能があるので、新システムで再ログインはしたくない
  • 既存システムにシングルサインオンする機能は存在しない

なぜJWTか?

正直JWTで認証することが一番良いとは思っていません。元のシステムの認証機能にOIDCでシングルサインオンできる機能をつけてもらったり、別途認証基盤を作るなどしたほうが、より良いシステムになると思っています。
ただ、実際問題として「既存システムに今更大きな開発を入れるのは無理です。」という話が出ました。既存の認証機能の変更や機能追加は、大規模な変更になるので簡単にはできません。

ですので、実現するために次のような条件が必要になると考えました。

  • 既存システムの変更は、既存機能に影響がない簡易な開発のみが可能なものとする
  • 認証情報の検証は新システム側で完結させる

そうなると、これを実現するのは電子署名ですね。という話になります。
独自フォーマットを自力実装もできなくはないですが、RFC7519で標準化されているJWTを使うのが無難と考えました。

なぜCognitoか?

JWTの検証するだけなら、適当なライブラリを使って自力実装も容易です。Cognitoを使ってもカスタム認証フローの中で自力でJWTの検証が必要ですので、Cognitoを通した方が手間がかかります。
にもかかわらず、あえてCognitoを使う理由は、主に次の2点が上げられます。

理由その1 : AWSのリソース間の連携をしたい

AWS上にアプリを作る上で複数のサービスを連携する設計にしています。例えば次のようなことはよくやります。

  • ファイルは、S3に置く
  • バックエンドの処理は、API Gatewayを経由して実行する

そうなった時に認証処理を実施した後にS3やAPI Gatewayにアクセスできる権限を付与する必要があります。そんな恐ろしい処理を自分で作りたくないです。 Cognitoを通して認証をすれば、Cognitoがやってくれるので安心できます。

理由その2 : 将来の拡張性を確保したい

Cognitoは複数の認証フローを定義できます。
なので、将来に次のような対応をすることになった時に、Cognitoの認証フロー関連の修正のみで対応できます。

  • 既存システムの認証機能の改修して、OIDCに対応させる
  • 既存システムから新規システムを独立させて単独で動かす

そういった大きな修正がない場合でも、認証部分をアプリ本体から切り離せるので、アプリ改修時に認証機能への影響はあまり気にしなくて良くなります。開発者が機能の開発に集中でき、その結果メンテナンス性が向上します。

実際にサンプル実装してみた

作ったもの

https://github.com/k-ibaraki/CognitoCustomAuthJWT

使い方はリポジトリ内のReadmeに書いたのでそちらを参照してください。
以下は、中身の解説になります。

中身の解説

前提として、言語はTypescript、LambdaのDeployにはServerless Frameworkを使用しました。

Lambdaについて

まずは、どのようなLambdaが必要かを考えます。この記事の一番上に貼ったAWSの公式ドキュメントを見ると、下図が載っています。

Lambdaが3本必要と書いてあります。なのでLambdaを3本書きます。
解説できるほど深く理解していないので、詳細な処理の中身を把握したい方は公式ドキュメントを読んでください。

define_auth_challenge.ts

https://github.com/k-ibaraki/CognitoCustomAuthJWT/blob/main/lambda/src/define_auth_challenge.ts

AWSの図を見ると、define_auth_challengeは、最初と最後に1回づつの計2回呼ばれます。
ですので、この関数は呼び出されたのが1回目と2回目を区別するためにif文で大きく2つに別れています。

  • event.request.session.length > 0のときは2回目と判断して、認証成功or認証失敗を判断します。
  • それ以外のときは初回と判断して、以降の独自認証の準備をします。

create_auth_challenge.ts

https://github.com/k-ibaraki/CognitoCustomAuthJWT/blob/main/lambda/src/create_auth_challenge.ts

ユーザーに独自のチャレンジを発行する関数です。
今回は特に何もせずに、後続に処理を回すことだけをしています。

verify_auth_challenge.ts

https://github.com/k-ibaraki/CognitoCustomAuthJWT/blob/main/lambda/src/verify_auth_challenge.ts

JWT認証を実施します。認証の中身を修正したいときは、主にこの関数を更新しましょう。
challengeAnswerとしてJWTを受け取り、その内容を検証し、結果をanswerCorrectに詰めて返します。
サンプルなのでJWTのSercretをとりあえず雑に環境変数から取ってきていますが、本番用に作るならSecretManager等を使うことをお勧めします。

カスタム認証に必要なLambdaは以上です。

もう1本のLambdaについて

カスタム認証に必要なLambdaは以上と書きましたが、リポジトリにはLambdaをもう1つ書いています。
https://github.com/k-ibaraki/CognitoCustomAuthJWT/blob/main/lambda/src/pre_sign_up.ts

役割は「ユーザーが作られた瞬間に認証済にすること」です。
今までのログイン時の認証とは違いユーザー作成時の処理になり、こちらのフローに該当します。
https://docs.aws.amazon.com/ja_jp/cognito/latest/developerguide/user-pool-lambda-pre-sign-up.html

Cognitoは本来ユーザー作成時にユーザーの認証が必要です。具体的にはユーザーがSMSやメールで確認する手順です。
しかし、今回の要件を考えると既存のシステムで認証済のユーザーは素通りさせたいです。なので、Lambdaを書くことで確認する手順を無理やりスキップさせています。

余談ですが、このトリガーを使って認証済にする処理はユーザーが自分でサインアップした時のみ有効です。管理者権限でユーザーを作成した時は動作しないので注意してください。例えば、AWSコンソールでユーザーを追加しても認証済になりません。(上記コードはif文で制限していますが、それとは関係なく無効です。。。)
今回はユーザー側からサインアップするので問題ありません。

serverless.tsを書く

Serverless Frameworkでデプロイする用に作ったserverless.tsも貼っておきます。
https://github.com/k-ibaraki/CognitoCustomAuthJWT/blob/main/lambda/serverless.ts

デプロイする

.envに必要な環境変数を書いた後にsls deployすれば、LambdaがデプロイされCognitoユーザープールのLambdaトリガーが設定されます。
Cognitoのコンソールからも設定できていることが確認できます。

アプリクライアントの設定

LambdaをデプロイしてCognitoに設定できたら、カスタム認証を許可したアプリクライアントを作っておきます。
Serverless Frameworkでも作れると思いますが、とりあえず手動でコンソールから作りました。

作り方ですが、まずCognitoユーザープールを開いたら、アプリケーションの統合タブを選択。その後一番下までスクロールしてアプリケーションクライアントを作成

認証フローでALLOW_CUSTOM_AUTHを選択して(デフォルトで選択されているはず)、アプリケーションクライアントを作成すればOKです。

(他のフローも必要であれば付けても問題ないですが、使う予定がないなら消しておくのが無難です。)

作成後、アプリクライアントIDが発行されるので、メモっておいてください。

フロントエンドのサンプルについて

TypeScript + React + Amplify SDKを使って、カスタム認証フローでログインするサンプルを書きました。

ユーザープールIDとアプリクライアントIDの設定

CognitoのユーザープールIDとアプリクライアントIDが必要なので、環境変数から設定できるようにしました。
https://github.com/k-ibaraki/CognitoCustomAuthJWT/blob/main/react_sample/src/aws-exports.ts

カスタム認証フローを指定

カスタム認証フローを使うことをAmplify Authに設定します。
https://github.com/k-ibaraki/CognitoCustomAuthJWT/blob/main/react_sample/src/App.tsx#L8-L10

サインアップ(ユーザー作成)

ユーザーがいないとログインもできないので、まずsignUpしてユーザーを作ります。
パスワードは使わないので適当な固定値を入れていますが、念の為ランダムな文字列とかにしたほうが安全だと思います。
https://github.com/k-ibaraki/CognitoCustomAuthJWT/blob/main/react_sample/src/App.tsx#L18-L26
この時、追加で作ったサインアップ前のLambdaが動いてユーザーを認証済にします。

サインイン

続いて、singnInをします。
パスワードは使わないですが、必須項目なので適当に空文字列を入れておきます。
https://github.com/k-ibaraki/CognitoCustomAuthJWT/blob/main/react_sample/src/App.tsx#L28

通常のパスワードでのログインだと、ユーザー情報が帰ってきてログイン完了になりますが、今回はまだ処理が続きます。

JWTを使って認証処理を実行

challengeNameがCUSTOM_CHALLENGEとなっていることを念の為確認して、
sendCustomChallengeAnswerにJWTを投入します。
https://github.com/k-ibaraki/CognitoCustomAuthJWT/blob/main/react_sample/src/App.tsx#L30-L33
エラーにならなければ、ユーザー情報が返ってきてログイン成功です。

あとは、帰ってきたユーザー情報の中にCognitoが発行したACCESSトークンやIDトークンが入っているので、(適切にCognitoにIAMを設定していれば)それらを使ってAWSのリソースにアクセスも可能です。

以上です!

NCDCエンジニアブログ

Discussion