Next.js + NextAuth.js + Rails APIを用いたマルチテナント開発メモ

本記事はNext.js + NextAuth.js + Rails APIを用いたマルチテナント開発メモです。

試行錯誤による開発を記録します。

マルチテナントに対する要件

マルチテナントの要件として、テナントごとにサブドメインを付与することが要件となります。

というのも当社では派遣スタッフの管理システムCrewLinkをSaaS(クラウドツール)として開発しています。

CrewLinkを利用する派遣会社に派遣スタッフの方がユーザー登録しマイページを持つ形となります。

派遣スタッフの方がCrewLinkを利用する複数の派遣会社に登録する可能性があるため、派遣会社ごとにサブドメインを用意する必要があります。

NextAuth.jsのサブドメインによるマルチテナント化の方法

NextAuth.jsを複数のサブドメインからログインできるようにする必要があります。

一方で通常の開発では、.envのようにURLを指定しているため、動的にサブドメインで運用する方法に頭を悩ますでしょう。

.env

・・・
NEXTAUTH_URL=https://www.example.com/
・・・

調べてみると、2023年11月3日の現在では、オフィシャルに動的にサブドメインを切り替える案内はないようです。

参考:https://github.com/nextauthjs/next-auth/issues/600

そのため、非公式の対応をとるかNextAuth.js以外の認証フレームワークを選定する判断が必要となります。

以降では、非公式の対応となるためご注意ください。

下記のように設定することでNextAuth.jsで動的なサブドメインの運用が可能となります。

.env

・・・
#NEXTAUTH_URL=https://www.example.com/
AUTH_TRUST_HOST=true
・・・

つまり、NEXTAUTH_URLを削除し、「AUTH_TRUST_HOST=true」を指定します。

当社の場合は、この設定で動作しました。

cookieのdomainにも任意のサブドメインが指定されているため、認証としても問題なさそうです。

念ためにNextAuth.jsのソースを「AUTH_TRUST_HOST」や「NEXTAUTH_URL」を中心にチェックをしましたが、問題は見つかりませんでした。

※ただしこれらの変数の明確な役割も見抜けませんでした。

この設定の副作用として、「NEXTAUTH_URL」が指定できません。

その結果、本番環境ではEmailやパスワードを入力してPOSTする際のformのaction先が「http://」となってしまいます。

そのため、ログイン画面(その他、登録・パスワード変更など)をオリジナルで作成する必要があります。

(ただし、サービスとして運営するのであればログイン画面のデザインや個別カスタマイズは発生するため大問題ではないでしょう。それでもめんどくさいのは、現時点ではオフィシャルサイトのカスタマイズ方法ではエラーが出るのです。。)

Next.jsのマルチテナント対応について

Next.jsにおけるマルチテナントとして、middlewareにてサブドメインを取得し、「[subdomain]」フォルダ配下へリライトする方式を検討しました。

セキュリティー的にも安心感が増すと感じたものの、結局はセッションcookieでサブドメインのアクセスを制御するだけなので、本質的にsubdomainフォルダーへのリライトは開発しづらくなるだけだと考えやめました。

万一アクセスできたとしても認証ができていなければRails APIもテナントごとのデータを返却しないだけですし。

Railsのマルチテナント対応について

corsについて

Rails APIサーバーに対して、サブドメインが違うリクエスト来ます。

そのため、corsのoriginsを複数のサブドメインから受け止める必要があります。

以下はrack-corsの設定です。

app_rails/config/initializers/cors.rb
Rails.application.config.middleware.insert_before 0, Rack::Cors do
  allow do
    origins /\Ahttp[s]?:\/\/.*.localhost:3000\z/

    resource '/api/*',
      headers: :any,
      expose: ['access-token', 'client', 'uid', 'token-type', 'expiry'],
      credentials: true,
      methods: [:get, :post, :put, :patch, :delete, :options, :head]
  end
end

devise_auth_tokenについて

Railsではdeviseやdevise_auth_tokenを用いてログイン処理をすることが多いでしょう。

deviseはemailやuidを用いて一意にしている仕様のため、usersテーブルにtenant_idカラムを付与するのは設計として良くないでしょう。

(※筆者は、emailとテナント情報の複合キーでカスタマイズ仕様としましたが、ログイン以外のパスワード変更やらOAuth処理やらで悪手と感じ戻しました)

ただし、ログイン時にログインしたサブドメインをusersテーブルに記録し、ログインしたテナントがどこかを保存するようにしました。

動作的にはfreee会計もこのような設計に感じます。

ログイン時に、postされるemailとpasswordに加え、subdomainを追加する必要があります。

※設定しなければsubdomainがpermitされずに値を受け取れません。

それは、下記で簡単に設定ができます。

app_rails/config/initializers/devise.rb

#config.authentication_keys = [:email]
config.authentication_keys = [:subdomain, :email]

ちなみに、当プロジェクトの場合、usersテーブルにはsubdomainという項目はなく、tenant_idで引き当てる必要があります。

つまり、tenant_idはtenantsテーブルのidであり、subdomainから引き当てる処理が必要となっています。

そのため、devise_auth_tokenのログイン処理をオーバーライドしてカスタマイズする必要があります。

将来、devise_auth_tokenのバージョンアップにハマる可能性があるため嫌でしたが、避ける方法が思いつかず下記の処理を加えています。

app/controllers/api/v1/users/sessions_controller.rb
module Api
  module V1
    module Users
      class SessionsController < DeviseTokenAuth::SessionsController
        def create
          super do |user|
            # リクエストからサブドメインを取得してuserに格納する
            subdomain = get_case_insensitive_field_from_resource_params(:subdomain)
            tenant = Tenant.find_by_subdomain(subdomain)
            if tenant.present?
              tenant_user = TenantUser.find_by(tenant_id: tenant.id, user_id: user.id)
              if tenant_user.present?
                user.current_tenant_id = tenant.id
                user.save!
              end
            end

            # TODO: テナントがない、テナントに所属していないなどの対策をする

          end
        end

      end
    end
  end
end

まとめ

本記事はマルチテナント化の現在の考えのメモです。

セキュリティーに対する考え方が変わり方式を改めるかもしれません。

もし、問題がある場合はご指摘いただけますと幸いです。