こんにちは、株式会社アルファドライブの @takano-hi です。 この記事は AlphaDrive Advent Calendar 2023 2日目のエントリです。 今日はテナントごとにサブドメインが異なるタイプのプロダクトで Keycloak を利用する場合に遭遇した問題と対処法についてお話しします。
背景
我々が開発を担当しているプロダクトはマルチテナント型の BtoB SaaS であり、顧客がアクセスする画面はテナントごとにサブドメインが割り振られています。 また認証プロバイダには Keycloak を、クライアントライブラリには @auth0/nextjs-auth0 を採用しています(詳しくは こちら の「ライブラリの選定背景」を参照ください)。
Keycloak では認証時の callback URL として、全てのサブドメインを valid redirect URIs という項目に登録しておく必要があります。(サブドメインの部分にワイルドカードが使えません。) そのため今までは新規テナントの導入が決まった際、毎回 Keycloak の管理画面から手動で valid redirect URIs に登録していました。
しかし一度、その手動のプロセスで入力ミスがあり、顧客にご迷惑をお掛けしてしまったことがあり、このプロセスの見直しをすることになりました。 目指すのは、テナントが増える度に手動で Keycloak の valid redirect URIs にサブドメインを追加しなくても済む状態です。
対応策
顧客がアクセスする画面のドメインが tenant_name.example.com で、Keycloak がホストされているドメインが auth.example.com だとします。 現在のログインフローは下記のようにオーソドックスな OpenID Connect のフローになっています。
ここで5の「tenant_name.example.com/api/auth/callback にリダイレクト」に注目します。 Keycloak から直接リダイレクトする URL のドメイン部分にテナント名が含まれているため、新規テナントに導入が決まる度に手作業での登録が必要になるというのが問題でした。 そこで Keycloak から直接リダイレクトする先を Next.js のエンドポイントではなく、 Keycloak の前段にある CloudFront に設けたリダイレクト用のエンドポイントに向けるという案を考えました。
図解すると下記のようになります。
5の部分が CloudFront を経由して Next.js にリダイレクトしているところが変更点です。 Keycloak の前段にある CloudFront は Keycloak と同じ auth.example.com にホストされており、パス名の /tenants/tenant_name の部分を見てリダイレクト先のサブドメインを動的に決めています。
CloudFront Functions のソースコードは下記のようになっています。 (※ CloudFront Functions は const や URLSearchParams が使えないなどの制約があり、少し古い書き方になっています。)
function handler(event) { var url = event.request.uri; if (!/^\/tenants\/[a-z0-9-]+\/callback/.test(url)) { throw new Error(`URL is invalid: ${url}`); } var tenantName = url.split("/")[2]; var authHost = event.request.headers.host.value; var host = authHost.replace(/^auth/, tenantName); var search = Object.keys(event.request.querystring) .map(function (key) { var value = event.request.querystring[key].value; return `${key}=${value}`; }) .join("&"); var newUrl = `https://${host}/api/auth/callback?${search}`; return { statusCode: 302, statusDescription: "Found", headers: { location: { value: newUrl } }, }; }
なおローカル開発環境では CloudFront Functions の代わりに開発環境用の nginx に同等のエンドポイントを用意して代用しています。
server { listen 80; listen [::]:80; server_name auth.localhost; location ~ ^/tenants/([a-zA-Z0-9\-]+)/callback$ { return 301 $scheme://$1.atlas-web.localhost/api/auth/callback?$query_string; } }
returnTo が上書きされる問題
ここまでで変更は十分だと思っていたのですが、この方式で実装してみると認証完了後のリダイレクトに問題があることが分かりました。 ログイン時に state に埋め込んだ returnTo にリダイレクトするのですが、この returnTo のホスト部分が callback 時のホストと異なる場合、 callback 時のホストで上書きされてしまうのです。
auth0-nextjs の内部実装を眺めた限りでは、ここで上書きされていました。
この挙動はオプションなどによって変更することができなかったため、 auth.example.com 上にさらに returnTo リダイレクト用のエンドポイントを追加することで対応することを考えました。
追加した CloudFront Functions のソースコードは下記のような内容です。
function handler(event) { var url = event.request.uri; var returnTo = url.match(/^\/tenants\/[a-z0-9-]+\/returnto(\/.*)$/)[1]; if (!returnTo) { throw new Error(`URL is invalid: ${url}`); } var tenantAlias = url.split("/")[2]; var authHost = event.request.headers.host.value; var host = authHost.replace(/^auth/, tenantAlias); var newUrl = `https://${host}${returnTo}`; var search = Object.keys(event.request.querystring) .map(function (key) { var value = event.request.querystring[key].value; return `${key}=${value}`; }) .join("&"); if (search) { newUrl += `?${search}`; } return { statusCode: 302, statusDescription: "Found", headers: { location: { value: newUrl } }, }; }
最終的なログインフローは下記のような図になります。
変更点は8の部分が CloudFront Functions を経由する形になっているところです。 これで無事にログインフローの全体が動作するようになりました。
議論
正直、この最終的な図を見て「やりたいことに対して複雑すぎないか」という意見も出るなど、チーム内で議論がありました。 Keycloak Admin REST API を利用して、テナント作成時に自動で valid redirect URIs を追加すれば良いのでは?という対案も出ました。
その議論の中で CloudFront Functions を経由した方式を採用するメリットを検討したところ、当初の目的としていた「テナント新規導入時の手作業がなくなる」だけでなく、次のような内容が挙げられました。
- 特定テナントだけでログインの問題が起きることがなくなる(今は顧客の環境に実際にログインして動作確認することができないので助かる)
- 何かログインに不具合が起きた際、テナントごとに値が正しく保存されているか Keycloak 管理画面を確認しに行く手間が減る
- ログイン周りのページや URL の構成を変更した際、全ての valid redirect URIs を変更しなくても済む
これらを鑑みた結果、最終的に本記事で説明した方式を採用することになりました。
まとめ
そもそもテナントごとにサブドメインが異なる問題に対応するために @auth0/nextjs-auth0 を採用しましたが、CloudFront Functions によるリダイレクトを利用することで Keycloak から直接リダイレクトする際のドメインは固定されたので、今後の検討次第では @auth0/nextjs-auth0 から NextAuth.js などに乗り換えの可能性も出てきました。 今後もより安全で効率的なログインフローについて考えていきたいと思います。