はじめに
はじめまして、Drawerグループ所属のもりやです。 キャディは入社して約2年になりますが、ブログ記事を書くのは初めてです。よろしくお願いします。
私は入社時から 製造業データ活用クラウドCADDi Drawer の開発に携わっており、最初のRBACベースの認可を私が中心に実装しました。 その関係から、今はIDチームで認証認可周りの開発を担当してます。
今回は、CADDi DrawerでSSOをサポートしたことについて、主にAuth0の観点で書きます。
おことわり
- この記事は、Auth0をある程度使ったことがある方向けに書いています。
- タイトルに「1年かけて」とありますが、開発着手からリリースまでの期間を指しています。 途中で他の機能開発をしていた期間も含まれており、丸々1年を全て開発に費やしたわけではない点にご留意ください。
CADDi Drawer とは
認証観点で簡単に書くと、CADDi Drawerはマルチテナント構成のSaaSで、認証にはAuth0という認証・認可のプラットフォームを使用しています。 アプリケーション側は、Next.jsと nextjs-auth0 というライブラリを使ってAuth0を利用しています。 1つのAuth0テナントに、複数のCADDi Drawerのテナントが存在する構成になっています。
CADDi Drawerは 多くの大企業でも使用いただいています。 傾向として、ユーザー数が多いテナントほどSSOを求める声は多くあり、今回はその要望に応えるためにSSOを実装しました。
補足として、以前に 独自の認証認可基盤を開発 していたこともありましたが、今のところはAuth0を使い続けています。
本記事の構成
大まかに以下のような構成になっています。
- 検討: Auth0でSSOを提供するために検討したこと
- 実装: Auth0を使ってSSOを実装したこと
- 今後: 作りたい機能や課題など
時系列順には書いていないので、その点もご留意ください。
また、文字数が多くなりそうだったためかなり端折っています。 もっと細かい部分も聞いてみたいと思われた方は、ぜひカジュアル面談で話しましょう。
1. 検討
Auth0 での SSO の提供方法(Auth0 Organizations)
CADDi Drawerで求められるSSOは、Googleログインのように全ユーザーに提供するものではなく、契約している各企業のIdPを、特定のテナントのみに紐付けてSSOを提供します。 そのため、単にアプリケーション全体で提供するのではなく、テナント単位に設定する必要があります。 調査した結果、こうした場合はAuth0 Organizationsという機能を使うと良いという結論に至りました。
Auth0 Organizations とは?
Auth0 Organizationsという機能を利用することで、複数の組織を管理し、組織ごとの設定(SSOなど)が簡単に実現できます。 データ構造としては、Organization(= CADDi Drawerのテナント)という情報が新たに登場し、そこにユーザー情報を紐づけるという形になります。 補足として、これまでは app_metadata を使って所属するテナントを管理していました。 Auth0 Organizationsは、このような独自の管理を使わずに、Auth0だけで組織を管理できます。
Auth0 Organizations を使った場合のトークンの変更
Auth0から発行されるIDトークン、アクセストークンに org_id
というクレームが追加されます。
また、設定を変更することで org_name
というクレームも付与されます。
これらの情報を使うことで、アプリケーション側でもどのOrganization(テナント)に所属しているユーザーなのかを判断できます。
ログイン体験の決定
Auth0 Organizationsを使うと、Auth0 Applicationの単位でログインの体験を決定できます。 ここはユーザー体験に重要なポイントになるので、PdMやCSチームと相談しながら決定しました。
Type of Users
ログイン可能なユーザーの種類です。以下の3つがあります。
Individuals
: Auth0 Organizationsを使わないBusiness Users
: Auth0 Organizationsが必須Both
: どちらでも可
Auth0 Organizationsを使う場合は Business Users
または Both
を選択する必要があります。
CADDi Drawerでは、全てのユーザーは何らかのOrganizationに所属する事を必須にしたいので Business Users
を選択しました。
Login Flow
ログインフローの選択肢です。以下の3つがあります。
Prompt for Credentials
: 最初にユーザーはメールアドレスを入力し、メールドメインに基づいてログイン方法 (Auth0 Connection) を決定しログインする方式Prompt for Organization
: 最初にユーザーがOrganizationを入力し、該当するOrganizationにログインする方式No Prompt
: 呼び出し側のアプリケーションで判断する方式
Prompt for Credentials
は一般的によく見られる形式で、既存の体験とも大きく変わらないので一見良い方法に見えました。
しかし、この体験を実現するAuth0のHome Realm Discovery (HRD) がCADDi Drawerとしては採用しづらいものでした。
HRDは、ユーザーが入力したメールアドレスではなく、メールアドレスの「ドメイン」に基づいて、ログイン方法(Auth0 Connection)を判断します。
例えば1つの企業で複数のテナントを契約している場合だと、SSOの提供に支障が発生する可能性があったため、HRDを使う判断はしませんでした。
Prompt for Organization
は、ユーザーが最初にOrganizationを入力する必要があるため、ユーザー体験としてはあまり良くありません。
また、特にユーザー数が多いテナントの場合、組織名を周知するのも大変というオペレーションの課題もあります。
よって、こちらも採用しませんでした。
このあたりは色々と検討したのですが、最終的には以下のような結論になりました。
- パスワードでログインするユーザーにはこれまで通りの体験を提供する
- SSOを利用するユーザーには、SSO専用のURLを使ってログインしてもらう
Auth0の設定としては Prompt for Credentials
を選択しつつ、アプリケーション側で専用URLでない場合はパスワードでのログインを行えるように制御しました。
nextjs-auth0を使ったコードのイメージは以下のとおりです(※実際に稼働しているコードとは異なります)
// 参考: https://github.com/auth0/nextjs-auth0/issues/701#issuecomment-1255350171 import { handleAuth, handleLogin } from "@auth0/nextjs-auth0"; const login = async (req, res) => { // 1. クエリパラメーターから Auth0 Organization の情報を取得 const organization: string | undefined = req.query.organization; // 2. Auth0 Organization が指定されていない場合は、パスワードでのログインを強制 const connection = organization === undefined ? undefined : "Username-Password-Authentication"; await handleLogin(req, res, { authorizationParams: { organization, connection, }, }); }; export default handleAuth({ login });
このようにすることで、既存の体験の維持と、SSOの提供を両立させることができました。
ユーザーデータの作り方
SSOを提供した場合、単純に作ると同一のメールアドレスで以下の2種類のユーザーが存在可能になります。
- パスワードでのログインを行うユーザー(以下「パスワードユーザー」)
- SSOでログインするユーザー(以下「SSOユーザー」)
これは、Auth0のConnectionが異なる場合は、一意性制約が効かないためです。 (Auth0のConnection単位で見れば一意になります)
メールアドレスとしては同じユーザーに見えますが、ユーザーIDは異なります。 CADDi Drawerでは、様々なデータをユーザーIDベースで管理しているので、システムとしては別のユーザーに見えます。 これは、個人の設定情報が共有できない、利用状況を把握しづらくなる、など色々と問題になりそうです。 メールアドレスをIDとして管理できるようにすれば解決できそうですが、大きな改修が必要になるため、今回は見送りました。
これも色々検討した結果、以下のような構成に落ち着きました。
- 全てのユーザーは、パスワードでのログイン(Username-Password-Authentication)をベースに作成する
- User Account Linking を使って、上記のユーザーにログイン方法だけ追加するような形式にする
- パスワードでのログインは、Auth0の機能的にOFFにできないので、Auth0 Actionsで制御する
こうすることで、SSOを使う場合でもメールアドレスに対して必ず1つのAuth0ユーザーとなりました。 初回のログイン処理時にUser Account Linkingをしたり、ログイン方法をチェックするなど、多少複雑にはなります。 しかし、メールアドレスに対してユーザーが一意になるので、システムとしてはシンプルに扱えるようになりました。
2. 実装
Auth0 Organizations への移行(とドメイン移行)
SSOのサポートのために、まずはAuth0 Organizationsへの移行しました。 大きな変更になるので、既存のログインとAuth0 Organizationsを使った新しいログインの両方のどちらも使える並行期間を設け、慎重に行いました。 また、同時にCADDi Drawerのドメイン移行の計画もあったため、SREチームと連携しながらユーザーに影響を与えないように進めました。
既存ユーザーのデータ移行
既存のデータベースにあるテナント情報から、Auth0にOrganizationを作り、所属するユーザーのデータを紐づけていきます。 Organizationに紐づける情報を追加するだけで、既存のユーザーデータには変更が発生しないので、割と気楽にできる作業でした。
移行は、以下のAPIを組み合わせて、全て自動で行うことができます。
- 全ユーザー情報の取得: https://auth0.com/docs/manage-users/user-migration/bulk-user-exports
- Organizationの作成: https://auth0.com/docs/api/management/v2/organizations/post-organizations
- Organizationとユーザーの紐づけ: https://auth0.com/docs/api/management/v2/organizations/post-members
注意点としてAuth0のRate Limitがそれなりに厳しいです(参考: Enterprise のRate Limit)。 本番で稼働しているので、安全に移行できるように、1リクエストごとにスリープ処理を入れ、Rate Limitに引っかからないようにしました。
Next.js で複数の Auth0 Application の対応
まずNext.js側では nextjs-auth0
の設定を2つ用意し、Auth0クライアントのインスタンスを切り替えることで、ログインの並行稼働を実現しました。
具体的には、HTTPの Host
ヘッダーに応じてAuth0のドメインとクライアントIDを切り替えることで、新旧どちらのAuth0 Organizationsにも対応できるようにしました。
実際のやり方としては、まずAuth0のApplicationを2つ用意します。
- 既存のAuth0 Organizationsが「無効」なApplication
- 新規のAuth0 Organizationsが「有効」なApplication
nextjs-auth0
は initAuth0
という関数を使ってAuth0のインスタンスを初期化でき、それぞれのApplicationに対応するAuth0のインスタンスを用意します。
より具体的な実装で言うと、以下のようなイメージでAuth0のインスタンスを切り替えて渡していました(※実際に稼働しているコードとは異なります)
import { initAuth0 } from '@auth0/nextjs-auth0'; export const getAuth0 = (req) => { const host = req.headers['Host']; if (host === NewDomain) { return initAuth0(/* 新ドメイン用の Auth0 設定 */); } return initAuth0(/* 旧ドメイン用の Auth0 設定 */); };
停止を伴う移行
以下の設定については並行稼働できないので、サービス停止を行って一斉に移行しました。
- Auth0のカスタムドメイン
- Auth0が発行したAccess Token (JWT) の検証につかうJWKsのURL
特に難しい点はなく、移行手順の準備や、Staging環境でのリハーサルを行っていたため、大きな問題なく移行を完了できました。 補足として、先に書いた通りドメイン移行と同時に行っていたので、このタイミングで旧ドメイン→新ドメインへのリダイレクト対応なども行っています。
ここまでで、ユーザーに気づかれることなく、ユーザーデータの構造変更を完了させました。
SSO 機能の実装
ここが本題ですが、実際のところSSO機能そのものはAuth0に任せるので、CADDi Drawer固有の実装が少しあった程度でした。 主に、Auth0 Actionsでの開発になります。
なお、CADDi Drawerのユーザー管理機能などの開発については省略します。
パスワードでのログインが禁止されているかのチェック
「ユーザーデータの作り方」で説明した通り、全てのユーザーはパスワードでのログイン方法を持ちます。 しかし、組織ポリシーでパスワードでのログインを禁止したいケースも発生することが想定されています。
Auth0のユーザーの app_metadata
に、パスワードでのログインが禁止されているかどうかのフラグをもたせます。
Auth0 Actionsのコードのイメージは以下のとおりです(※実際に稼働しているコードとは異なります)
exports.onExecutePostLogin = async (event, api) => { const isDisablePasswordLoginUser = event.user.app_metadata.disablePasswordLogin === true; const isLoginWithPassword = event.connection.strategy === "auth0"; if (isDisablePasswordLoginUser && isLoginWithPassword) { // ※注意: api.session.deny だと Auth0 セッションが残り続け、自発的に Cookie を消さない限りログインできなくなる api.session.revoke("disallow_password_login"); return; } };
SSO の初回ログイン時の User Account Linking 処理
「ユーザーデータの作り方」で説明した通り、全てのユーザーはパスワードでのログイン方法を持ちます。 よって、SSOで初回ログインした後に、User Account Linkingを行い、パスワードユーザーと統合する処理が必要になります。
前提として、ユーザーを招待する時に app_metadata.linkUserTo
に統合先のユーザーIDを設定しておきます。
そして、以下のようなAuth0 Actionsを実装し、User Account Linkingを行います(※実際に稼働しているコードとは異なります)
exports.onExecutePostLogin = async (event, api) => { // User Account Linking が必要か判定 const loginUserId = event.user.user_id; const linkUserTo = event.user.app_metadata?.linkUserTo; if (linkUserTo == null || linkUserTo === loginUserId) { return; // Skip } const primaryUserId = linkUserTo; const secondaryUserId = loginUserId; const secondaryUserProvider = event.user.identities[0].provider; // Auth0 Organization が一致するかのチェック const managementApiClient = "(省略)"; const {data: primaryUserOrganizations} = await managementApiClient.users.getUserOrganizations({id: primaryUserId}); const primaryUserOrganizationIds = primaryUserOrganizations.map((org) => org.id); const secondaryUserOrganizationId = event.organization.id; if (!primaryUserOrganizationIds.includes(secondaryUserOrganizationId)) { api.access.deny("(Auth0 Organization の不一致エラー)"); return; } // User Account Linking の実行 await managementApiClient.users.link( {id: primaryUserId}, {user_id: secondaryUserId, provider: secondaryUserProvider}, ); // ※下記で解説 api.access.deny( `request_re-login:${ JSON.stringify({ organization: event.organization.name, connection: event.connection.name }) }`, ); }
ポイントは、User Account Linking成功後に api.access.deny
でカスタマイズしたエラーを返す部分です。
まず前提として、このログインフローは「SSOユーザー」でログインした状態ですが、終了時点では「パスワードユーザー」に統合され、「SSOユーザー」は存在しなくなります。 なので「パスワードユーザー」に切り替える必要があります。
実は api.authentication.setPrimaryUser()
というメソッドも用意されていますが、今回のCADDi Drawerの設定では使えませんでした。
以下を読む限り、Auth0 Organizationsで "Prompt for Credentials" を使用している場合は api.authentication.setPrimaryUser()
を利用できないようです。
苦肉の策として、カスタマイズしたエラーに organization
と connection
の情報を持たせ、Next.js側でハンドリングして、再度ログインする、という方法を取っています。
補足すると、再度ログインするといってもSSOなので、通常は何度かリダイレクトを挟むだけでユーザーの操作は不要です。そこまで大きな体験の悪化はありません。
データの不整合チェック
今回採用した設定だと、Auth0 Organizationsに所属していないユーザーはログインができなくなります。 万一そういったデータが発生した場合に備え、不整合を検出するスクリプトを作って自動でチェックをできるようにしました。
このスクリプトは、GitHub Actionsで毎日実行するように設定して、何かあれば通知が来るようにしました。 日次なのでリアルタイム性は若干低いものの、これによってデータに問題はないことを毎日確認でき、安心できました。
ちなみに、この記事を書いている時点では、テストデータなどを除いて不整合は発生していません。
3. 今後
ログイン体験の向上
現状では、テナントごとにSSO専用のURLを発行し、それを使用した場合のみSSOでログインできます。
これはログアウト後に再度SSOでログインするすることができないので、ユーザー体験が悪いなどの問題があります。 また、既存のユーザーにSSO専用URLが浸透しづらいというオペレーションの課題もあります。
専用URLなしでもSSOでログインできるように、ログインフローを見直しています。
ユーザー自身でのSSOの設定
現在は、キャディ側でSSOを設定していますが、ユーザー自身でSSOの設定を行えるようにしたいと考えています。 Self-Service Single Sign-On を使えないか検討しています。
おわりに
調査や下準備など含めると長い開発でしたが、ひとまず無事にSSOを提供できてよかったです。 まだまだ課題はありますが、少しずつ改善していきたいと思います。
We are hiring!
キャディでは認証認可領域のエンジニアも絶賛募集中です!