
はじめに
アプリケーションサービス本部ディベロップメントサービス1課の森山です。
re:Invent を直前に控え、大型アップデートが日々行われていますね。
今回は以下のアップデートを検証してみます。
Application Load Balancer (ALB) が JSON Web Token (JWT) の署名、有効期限、任意のクレームを検証できるようになり、これまでアプリケーション側で対応していた JWT 検証を ALB にお任せできるようになりました。
これまで API Gateway で類似した機能が提供されていましたが、これが ALB でもできるようになりましたね!
今回はこの新機能をマルチテナント SaaS のテナントルーティングに使えないか?と考えてみたので、検証してみた記事です。
構築内容
今回は以下のようなものを作ってみます。

まずはCognito でテナントユーザを認証します。
その後、各テナントユーザのリクエストをテナント間でリソース共有する、プール型の ALB がテナントルーティングを行い、それぞれテナントごとに構築されたサイロ型のコンピューティングリソースにアクセスするシナリオです。
(今回は簡単に構築するために AWS Lambda を利用しています。)
マルチテナント SaaS 固有の用語やアプローチが登場していますが、詳細については下記を参照ください。
また、Cognito のマルチテナント戦略についても下記ドキュメントを参照ください。
作ってみる。
では、作ってみます。
今回も CDK で IaC 化していますので、よければご利用ください。
また、手順については新機能部分を中心に記載し、それ以外の部分は簡潔に記載させていただきます。
Cognito ユーザプールの作成
任意のユーザプールに SPA タイプのクライアントを作成します。(他のタイプでも問題ありません。)
そして、それぞれ tenant-A、tenant-B のグループを作成しておきます。
今回は CDK で作成しました。(コードは紹介した Github リポジトリを参照ください。)
動作確認用ユーザの作成
次にテナント A、B ごとにユーザを2つ作っておきます。
今回は CLI で実施しています。
# 環境変数設定 USER_POOL_ID=xxxxxxxxxxxx # userA作成 aws cognito-idp admin-create-user \ --user-pool-id $USER_POOL_ID \ --username userA \ --user-attributes Name=email,Value=userA@example.com Name=email_verified,Value=true \ --message-action SUPPRESS # userAをtenant-Aグループに追加 aws cognito-idp admin-add-user-to-group \ --user-pool-id $USER_POOL_ID \ --username userA \ --group-name tenant-A # userAのパスワード設定 aws cognito-idp admin-set-user-password \ --user-pool-id $USER_POOL_ID \ --username userA \ --password 'YourPassword123!' \ --permanent # userB作成 aws cognito-idp admin-create-user \ --user-pool-id $USER_POOL_ID \ --username userB \ --user-attributes Name=email,Value=userB@example.com Name=email_verified,Value=true \ --message-action SUPPRESS # userBをtenant-Bグループに追加 aws cognito-idp admin-add-user-to-group \ --user-pool-id $USER_POOL_ID \ --username userB \ --group-name tenant-B # userBのパスワード設定 aws cognito-idp admin-set-user-password \ --user-pool-id $USER_POOL_ID \ --username userB \ --password 'YourPassword123!' \ --permanent
バックエンドの作成
バックエンドは Lambda を利用し、環境変数TENANT_IDの値が異なるだけの2つの Lambda を作成します。
TENANT_ID はそれぞれ、tenant-A、tenant-B としています。
import { Hono } from "hono"; import { handle } from "hono/aws-lambda"; const app = new Hono(); app.get("/", (c) => { return c.json({ message: "Hello from Hono on Lambda!", tenantId: process.env.TENANT_ID || "not set", }); }); export const handler = handle(app);
また、作成した Lambda はそれぞれ個別のターゲットグループに所属させておきます。

VPC の作成
ALB を利用するので、VPC を作成しておきます。
今回は NAT Gateway 不要ですが、Internet Gateway 等、ALB が外部通信できる環境は必要です。(JWKS エンドポイントへの通信のため)
VPC は CDK で作成しています。
ALB の作成
最後に Application Load Balancer の設定をしていきます。
JWT の検証は、HTTPS リスナーの事前ルーティングアクション内、トークンを検証から実施できます。

なお、HTTP リスナーではこの設定はできませんので、ご注意ください。

トークンを検証を選択すると、専用の設定項目が出てきますので、これらを設定していきます。

JSON ウェブキーセット (JWKS) エンドポイント
JWT の署名検証に使用する公開鍵を取得するエンドポイントを指定します。
Cognito の場合、以下の形式になります。
https://cognito-idp.{region}.amazonaws.com/{userPoolId}/.well-known/jwks.json
ALB はこのエンドポイントから公開鍵を取得し、JWT の署名が正しいかを検証します。
なお、公開鍵取得時に通信が発生しますので、ALB のセキュリティグループや、所属する VPC の設定でインターネットへの通信が可能となる設定にしておいてください。
通信が失敗すると、ALB は500 Internal Server Errorを返却します。
発行者
JWT の iss (issuer) クレームと照合する値を指定します。
Cognito の場合、以下の形式になります。
https://cognito-idp.{region}.amazonaws.com/{userPoolId}
ALB は JWT 内の iss クレームがこの値と一致するかを検証し、信頼できる発行者から発行されたトークンであることを確認します。
複数設定はできない模様なので、ユーザプールと HTTPS リスナーの関係性は1:1にしておく必要がありそうです。
カスタムクレーム
JWT 内の任意のクレームに対して検証条件を設定できます。

今回は cognito:groups クレームを使用して、テナントごとのアクセス制御を実現しています。
例えば、tenant-A グループに所属するユーザのみを許可する場合、以下のように設定します。
- クレーム名:
cognito:groups - クレーム値:
tenant-A(文字列配列として設定)
今回は検証していませんが、複数のクレームを設定することも可能で、すべての条件を満たす場合のみリクエストが通過します(AND 条件)。
設定後はこのような形です。(テナント A 分)

今回はテナントごとに以下の通り、リスナールールを作成します。
テナント A 用のルール
- パス条件:
/tenant-A/* - JWT 検証:
cognito:groups=tenant-A - 転送先: テナント A の Lambda ターゲットグループ
テナント B 用のルール
- パス条件:
/tenant-B/* - JWT 検証:
cognito:groups=tenant-B - 転送先: テナント B の Lambda ターゲットグループ
このように、パスとグループの組み合わせで、各テナントのユーザが自分のテナントのリソースにのみアクセスできるようになります。
また、バックエンドの Lambda にリクエストを転送する際には、URL リライト機能を利用し、/tenant-[A|B]/の部分を/にリライトしておきます。
リライト機能に関しては、以前記事を書いているので、よかったら以下を参照ください。
また HTTPS リスナーなので、サーバ証明書が必要ですが、今回は ALB のデフォルトの DNS 名 (xxx.elb.amazonaws.com) を利用するため自己署名証明書を使用しています。
動作確認
では、いくつかのパターンで動作確認を実施してみます。
Authentication ヘッダなし
まずは、JWT を付与せずにリクエストしてみます。
curl -k https://xxxxxx.ap-northeast-1.elb.amazonaws.com/tenant-A/
401 Authorization Requiredエラーが返ってきました。想定通りの動作です。
<html> <head> <title>401 Authorization Required</title> </head> <body> <center><h1>401 Authorization Required</h1></center> </body> </html>
Lambda までは通信が届かず、ALB でブロックしてくれています。
Authentication ヘッダあり(テナント A のユーザがテナント A のエンドポイントにアクセス)
次にテナント A のユーザのアクセストークンをベアラトークンとして付与してみます。
CLIENT_ID=xxxxxxxxxx ACCESS_TOKEN=$(aws cognito-idp initiate-auth \ --auth-flow USER_PASSWORD_AUTH \ --client-id $CLIENT_ID \ --auth-parameters USERNAME=userA,PASSWORD=YourPassword123! \ --query 'AuthenticationResult.AccessToken' \ --output text) curl -k -H "Authorization: Bearer $ACCESS_TOKEN" \ https://xxxxxx.ap-northeast-1.elb.amazonaws.com/tenant-A/
テナント A 用の Lambda がコールされていることを確認しました!
{"message":"Hello from Hono on Lambda!","tenantId":"tenant-A"}%
なお、今回の機能ではアクセストークンのみが利用できます。 ID トークンは利用できないので、カスタムクレームを使う場合は Lambda トリガーが必要となります。
Authentication ヘッダあり(テナント B のユーザがテナント B のエンドポイントにアクセス)
同様にテナント B で確認してみます。
CLIENT_ID=xxxxxxxxxx ACCESS_TOKEN=$(aws cognito-idp initiate-auth \ --auth-flow USER_PASSWORD_AUTH \ --client-id $CLIENT_ID \ --auth-parameters USERNAME=userB,PASSWORD=YourPassword123! \ --query 'AuthenticationResult.AccessToken' \ --output text) curl -k -H "Authorization: Bearer $ACCESS_TOKEN" \ https://xxxxxx.ap-northeast-1.elb.amazonaws.com/tenant-B/
こちらも問題ないですね。
{"message":"Hello from Hono on Lambda!","tenantId":"tenant-B"}
Authentication ヘッダあり(テナント B のユーザがテナント A のエンドポイントにアクセス)
次に、テナント B のユーザがテナント A のエンドポイントにアクセスしてみます。
CLIENT_ID=xxxxxxxxxx ACCESS_TOKEN=$(aws cognito-idp initiate-auth \ --auth-flow USER_PASSWORD_AUTH \ --client-id $CLIENT_ID \ --auth-parameters USERNAME=userB,PASSWORD=YourPassword123! \ --query 'AuthenticationResult.AccessToken' \ --output text) curl -k -H "Authorization: Bearer $ACCESS_TOKEN" \ https://xxxxxx.ap-northeast-1.elb.amazonaws.com/tenant-A/
401 Authorization Requiredが発生し、アクセスが拒否されています。
<html> <head> <title>401 Authorization Required</title> </head> <body> <center><h1>401 Authorization Required</h1></center> </body> </html>
まとめ
前回のリライト機能に続き、ALB の新機能について紹介させていただきました。
ALB を使うケースではこれまで JWT の検証・認可処理は、自身で実装する必要があったのが、今回のアップデートで ALB にお任せできるようになり、より価値のある部分に注力できるようになりましたね。
誰かのお役に立てれば幸いです!