every Tech Blog

株式会社エブリーのTech Blogです。

Next.js を用いたマルチテナント・マルチサービス開発

はじめに

はじめまして、2023 年 4 月から新卒入社しretail HUBで Software Engineer をしているほんだ(@hon_d7174)です。Go が好きです! 現在、私たちは Next.js を使用して新規プロダクトを開発しています。このブログでは、私たちが取り組んでいるプロジェクトの中で工夫した点についてお話ししたいと思います。 また、既存のプロダクトを Next.js 13 へ移行した際の流れや工夫についてはこちらの記事をご覧ください:Next.js の Pages Router から App Router への移行に挑戦してみた

プロダクト概要

当社のプロダクトは、小売様向けにアプリを提供しています。このアプリには、クーポンやお知らせ、アプリユーザーの動向を管理するダッシュボードが含まれており、小売様はこのダッシュボードを使用してアプリのユーザーやコンテンツの管理を簡単に行うことができます。 当社のプロダクトは、以下のような特徴を持っています。

  1. マルチテナントアーキテクチャ
    各小売様は、それぞれ独自のテナントとしてシステムを利用します。これにより、異なる小売様間でのデータやリソースの分離が実現されています。
  2. アクセス制御
    各テナントやユーザーには、閲覧・操作可能なリソース、利用可能なサービスが異なります。アクセス制御により、セキュリティを強化し、情報の漏洩や不正な操作を防ぎます。

システム構成

システム構成図

私たちの作成しているプロダクト簡単なシステム構成図は上記のようになっています。この中の web client と BFF に Next.js を用いています。

Library

以下は今回説明する内容に登場する主な Library になります。

  1. SWR
  2. Auth.js(旧 NextAuth)

実装

プロダクト概要で述べたアクセス制御について一部実装について説明します。

Auth.js を用いた認証・認可の実装

はじめに認証・認可には Auth.js を用いています。Auth.js の interface を下記のように拡張しユーザー情報や Token を管理しています。Auth.js の cookie で管理することにより client side, server side で共通の状態管理が可能になります。下記では本プロダクトで実際に client side, server side で利用するサービス、テナントに関する情報を設定しています。

declare module 'next-auth' {
  interface User extends User {
    services: string[];
    chain: string;
    chains: string[];
    access_token: string;
    expires_at: number;
  }
  interface Session extends Session {
    user: {
      id: string;
      services: string[];
      chain: string;
      chains: string[];
    } & DefaultSession.user;
    access_token: string;
    expires_at: number;
  }
}

declare module 'next-auth/jwt' {
  interface JWT {
    services: string[];
    chain: string;
    chains: string[];
    access_token: string;
    expires_at: number;
    error?: 'RefreshAccessTokenError';
  }
}

次に client side において Auth.js で作成した JWE のデータにアクセスする方法についてです。
client side からアクセスする際にはuseSession()を用います。client side から JWE の更新が必要な際にはuseSession()updateと Auth.js の jwt callback を下記のように実装する必要があります。

const { data: session } = useSession();
<Menu mode="horizontal" items={chains} onClick={({ key }) => update({ chain: key })} {...props} />

jwt callback を下記のようにすることによりupdateで更新された session の内容を jwt にも反映することが可能になります。

callbacks: {
  async jwt({ token, user, session, trigger }) {
    if (user && trigger === 'signIn') {
      token.sub = user.id;
      token.email = user.email;
      token.name = user.name;
      token.services = user.info['services'];
      token.chain = user.info['mart'][0];
      token.chains = user.info['mart'];
    }
    // sessionが更新された際にtokenも更新する。
    trigger === 'update' && session.chain && (token.chain = session.chain);
      return token;
    },

middleware の実装

middleware では主に認証・認可状態の検証と HTTP リクエストヘッダの共通化、アクセス制御の実装を行なっています。 Next.js 13 の middleware は page 遷移時と route handler へのアクセス時の両方で同一の middleware が使われます。 middleware では hooks のような client side でしか使えない処理は行えないことに注意する必要があります。 認証・認可状態の検証は client side で用いていたuseSession()ではなく Auth.js によって生成された JWE を取得することができるgetToken()を用いて検証します。

export default async function middleware(req: NextRequest) {
    const newRequest = new NextRequest(req, req);
    const { pathname } = req.nextUrl;
    const token = await getToken({ req: req });
    const accessToken = token.access_token;
    // tokenの有無を検証する
    const isAuthenticated = !!token;

-------------------------------------省略--------------------------------------

    // 認証が必要なページへの遷移、BFFへリクエストの際に検証を行う
    if (!isAuthenticated) {
        return NextResponse.redirect(`${process.env.BASE_URL}/signIn`);
    }

-------------------------------------省略--------------------------------------

}

次に HTTP リクエストヘッダの共通化についてです。
本プロダクトでは以下の二つの header が主に共通で使われています。 一つ目は認証が必要な page, BFF へリクエストする際に必要な Authorization header です。

// AccessTokenをAuthorization headerに追加する。
newRequest.headers.set('Authorization', `${accessToken}`);

二つ目は各種リソースサーバーでテナントごとに異なる処理を行う際に必要なテナント header です。

// テナント情報が必要なエンドポイントか確認、必要な場合はheaderを追加する。
if (isMartAPIRoute(pathname)) {
    const chain = token.chain;
    newRequest.headers.set('X-Mart-Chain', chain);
}

SWR を用いた repository の実装

下記が BFF へのリクエストを行う client の実装になります。 ユーザー識別子(userID)とテナント情報(chain)を Auth.js のuseSession()を用いて取得することで認証・認可状態の検証を行います。またこれらを cache key に含めることで同一デバイスで異なるユーザーがアクセスする状態やユーザーが複数のテナントを跨いで利用するケースで不整合が起きないようにしています。加えてuserIDまたはchainが falsy な値の場合nullを key として渡すことで認証・認可状態が正しくないときにリクエストが行われないようにしています。

function useCoupon(id: string) {
    // Auth.jsから認証・認可情報、ユーザー関連情報を取得する。
    const { data: session } = useSession();
    const chain = session?.user?.chain;
    const userID = session?.user.id;
    return useSWR(chain && userID ? [`/api/coupons/${id}`, chain, userID] : null, getCouponFetcher, {
        suspense: true,
        revalidateOnMount: false,
    });
}

async function getCouponFetcher([url]: [string]): Promise<Coupon> {
  const res: Coupon = await fetch(url, {
  }).then(async (res) => {
    if (!res.ok) {
      const { message }: ErrorMessage = await res.json();
      throw { message: message, code: res.status };
    }
    return res.json();
  });
  return res;
}

終わりに

マルチテナントのアプリケーションということでユーザーに紐づく情報だけでなく現在そのユーザーがどのテナントに関連するリソースを操作しているかということを考慮する必要がある点が特異であると感じました。また詳細な権限設定はまだ実装していないのですがそれらを考慮するとさらに複雑性が増すのでより一層工夫が必要だと感じました。 マルチテナント・マルチサービスの開発を行おうと考えている人の参考になれば幸いです。 ここまで読んでいただきありがとうございました!