Amazon Cognito のユーザプール一意化するときに気をつけたいこと
はじめに
Linc'well では 2024 年に「認証基盤チーム」を立ち上げ、共通 ID・認証まわりの生産性向上とセキュリティリスクの低減に取り組んでいます。
今回、以下の問題あたりを中心に書きます。
- ユーザプール一意化の問題
- AdminLinkProviderForUser を使った解消方法と料金の落とし穴
認証機能の変遷
Devise + ID/パスワード認証
当社が支援する医療機関クリニックフォア予約システムを提供開始したのが 2018 年頃。
当初は Ruby on Rails の Devise を使った、メールアドレス+パスワード認証(いわゆる ID/パスワード認証)でした。
- メールアドレス確認は任意
- RFC に違反したメールアドレスも登録できる
という当時としてはシステムそのものの立ち上げを優先させた設計でした。
OIDC ログインのために Amazon Cognito を導入
2020 年 3 月、LINE ログインを組み込むために Amazon Cognito を導入し、ID/パスワード認証に加えて OIDC 認証を追加しました。
バックエンドで管理していたユーザ情報を Cognito に移すために、Cognito の Lambda トリガーによるユーザマイグレーションを採用しています。(他には一括インポートも選択肢としてあります。 参考)。
この Lambda トリガーがかなり複雑で、仕様を把握している人が限られたり、ちょっとした修正も怖くて触れない状態でした。複雑な要因として、メールアドレス確認が任意だったこともあります。
認証基盤チームの発足
その後、提供するサービスが増えるにつれて
- プロダクトごとに Cognito(Amplify Auth)を直接叩く認証実装が増殖
- 仕様変更のたびに各チームへ展開・改修が必要
- 誰が認証のオーナーなのかが曖昧
といった状態でしたので、課題の改善に取り組むべく 認証基盤チーム を組成し、共通認証機能の独立と Cognito 周りの技術負債の解消を進めることになりました。
認証基盤の独立化
サービス数が増える中で、最初は各プロダクトがそれぞれ Cognito へ Amplify Auth を利用して ID/パスワード認証と LINE・Google ログインといった OIDC 認証を実装していました。
イメージとしてはこんな構成です。
これをプロダクト側からは 共通認証ページに飛ぶだけ、共通認証ページが Cognito を呼ぶ構成に変えます。
本当はすぐに共通認証ページ(認可サーバー)まで作りたかったのですが、その前に Cognito ユーザプール側の負債 を片付ける必要がありました。特に大きかったのが「ユーザプールの一意化」です。
Amazon Cognito ユーザプールの一意化
何が課題だったのか
Amazon Cognito ユーザプールには、大きく 2 種類のユーザがいます。
- ネイティブユーザ:ID/パスワード認証のユーザ
- フェデレーションユーザ:Google / LINE など外部 IdP のユーザ
当時は、サービス側で採番した UUID を Cognito の カスタム属性 に入れてユーザを一意に特定していました。
| username | email_verified | 確認ステータス | custom:uuid | |
|---|---|---|---|---|
| Google_xxx | user_1@example.com | はい | 外部プロバイダー | user_1_uuid |
| uuidxxx-xxx | user_1@example.com | はい | 確認済み | user_1_uuid |
ユーザプールの制約上、カスタム属性でユーザ検索ができないです。また、サービス側でも Cognito の username を持っていないという状況だったので、ユーザプール側でユーザを一意に特定できないことが保守性の低い状態になっていました。
AdminLinkProviderForUser で解消する
課題を解消するために、ネイティブユーザに対してフェデレーションユーザを リンク する方針に切り替えました。
ここで使ったのが AdminLinkProviderForUser です。
ざっくりとした進め方は次の 3 ステップです。
- 新規登録ユーザ: 以降は最初からネイティブユーザ+フェデレーションをリンクする
- 既存アクティブユーザ: ログイン時に AdminLinkProviderForUser を実行してリンクする
- 非アクティブユーザ: バッチ処理で段階的にリンクしていく
MAU 課金の落とし穴
ここでやらかしたのが MAU(Monthly Active Users)課金 です。
Cognito の料金における「アクティブユーザ」には、AdminLinkProviderForUser を実行したユーザも含まれる使用のため、非アクティブユーザが多い状態で一気にリンク処理を走らせると、その月だけ MAU がドカッと増えます。
弊社もここに気づくのが遅く、ある月の Cognito の費用が 通常の 10 倍以上 になってしまいました。完全に反省ポイントです。
ふりかえり
マイグレーションは 2 回やっている
整理すると、今回の歴史はこうです。
- Rails / Devise ベースのユーザ管理 → Amazon Cognito へのマイグレーション
- Amazon Cognito ユーザプール内での一意化(AdminLinkProviderForUser)
正直、最初の Cognito 導入には立ち会っていないので「こうしておけばよかった」と言うだけになってしまうのですが、今の視点で見るとこう思います。
- Cognito で ID/パスワード + OIDC をやるなら
最初から AdminLinkProviderForUser 前提 で設計しておいた方がよい - メールアドレス確認を省略した状態で運用すると、
後から OIDC と統合するときに 本人確認フローが二重になる
ユーザ数がそれなりに増えてから AdminLinkProviderForUser で整理するのは、工数や Cognito の料金の両面で苦労します。
CVR を下げたくなくてメールアドレス確認を省きたくなることはあると思いますが、
- OIDC ログイン時に同じメールアドレスでも自動で結びつけられない
- 結局あとから本人確認フローを追加することになり UX も悪化する
など、後でツケを払うことになります。
さいごに
現在は、Cognito 周りの大きな負債を返し終えて、共通認証ページを土台に 認可サーバーの開発 も進められるようになりました。
Amazon Cognito 自体は便利なサービスですが、ユーザをどう一意に扱うか、ネイティブ / フェデレーションをどう設計するかを最初に決めておかないと、後からかなり大変です。
これから Cognito を導入するチーム、すでに運用していて整理に悩んでいるチームの何かしらの参考になれば幸いです。
Discussion