ホワイトラベルSaaSを構築する上で最も困難だったのは、AIやカスタムドメイン、課金システムではなかった。たった1つのログインフォームだった。

私はVoiceDashを開発している。これは代理店がAI音声エージェントを自社ブランドで顧客に再販できるホワイトラベルプラットフォームだ。表向きは音声製品だが、実際のところ私が夜も眠れなかったのは認証だった。なぜなら、ホワイトラベル製品にはほとんどのアプリが直面しない構造的問題があるからだ。1つのログインフォームが、完全に異なる2種類の人々に対応する必要があり、どちらも互いになることは決して許されない。

これは、1つの認証プロバイダーと1つの小さな識別子フィールドでそれを解決した方法、そして全体を破壊しかけたエッジランタイムの問題についての話だ。

決して重複してはならない2種類のユーザー

階層構造は以下のようになっている。代理店がサインアップする。その代理店はWorkspaceとなる。Workspace内では、代理店のスタッフがWorkspaceMembersとなる。代理店は次にClientsを作成し、各ClientにはそれぞれのClientMembers(実際に代理店ブランドのポータルにログインするエンドユーザー)が存在する。

つまり、ログインページにアクセスする人間には2種類ある:

  • 代理店ユーザー。私の有料顧客。彼らはメールアドレスとパスワードでログインし、クライアント、エージェント、課金、ブランディングのすべてを管理する。
  • クライアントメンバー。私の顧客の顧客。彼らは代理店ブランドのポータルにログインし、自分のクライアントのデータしか見ることができず、VoiceDashの存在に気づくことはない。

何よりも重要な不変条件:クライアントメンバーは代理店ルートにアクセスできず、他のクライアントのデータを見ることも決してあってはならない。これが破られれば、ある代理店の顧客が別の代理店の顧客情報を読むことになる。それは単なるバグではなく、ビジネスの終わりを意味する。

魅力的な選択肢は、2つの認証システムを構築することだ。2つのプロバイダー、2つのセッション形状、2セットのミドルウェア、すべてを2つにする。私はすべてを2つに保つことを望まなかった。そこで、私は1つだけ作った。

1つのプロバイダー、1つの識別子

VoiceDashはNextAuthと単一のCredentialsProviderで動作している。トリックは、credentialsにtypeフィールドを追加することだ。値は"agency"または"client"のいずれかで、authorize関数はこの値によって分岐する:

async authorize(credentials) {
  if (!credentials) return null;
  const type = (credentials.type as string) || "agency";

  if (type === "client") {
    // クライアントメンバーはloginIdまたはメールアドレスでログイン、パスワードは任意
    const loginId = credentials.loginId as string;
    if (!loginId) return null;
    let clientMember = await prisma.clientMember.findUnique({
      where: { loginId },
      include: { user: true, client: { include: { workspace: true } }, role: true },
    });
    // ...メールアドレスによるフォールバック検索、パスワードが設定されている場合のみ検証...
    return {
      id: clientMember.user.id,
      clientId: clientMember.clientId,
      workspaceId: clientMember.client.workspaceId,
      role: clientMember.role?.name || "Member",
      type: "client",
    };
  }

  // 代理店ユーザーはメールアドレス+パスワードでログイン
  const user = await prisma.user.findUnique({ /* ... */ });
  // ...bcrypt比較...
  return {
    id: user.id,
    workspaceId: member?.workspaceId || null,
    role: member?.role || "MEMBER",
    type: "agency",
  };
}

Enter fullscreen mode Exit fullscreen mode

2つの分岐は実際に異なる。代理店ログインは通常のメールアドレスとパスワードだ。クライアントログインはloginIdまたはメールアドレスを受け付け、パスワードは任意だ。これは、一部の代理店がパスワードなしでクライアントをオンボーディングし、ログインIDだけでログインさせるためだ。2つのフロー、2つの検索、2つの形状の返却ユーザー。しかし1つのプロバイダーで、どちらもtypeを含むオブジェクトを返す。

このtypeが、セキュリティモデル全体を一言で表している。

トークンに組み込み、どこでも読み取る

返却されたユーザーオブジェクトだけでは不十分だ。将来のすべてのリクエストにまで存続する必要がある。VoiceDashはJWTセッションを使用しているため、識別子は一度トークンに書き込まれ、すべてのリクエストで読み戻される:

// jwt callback: user -> token, on sign in
token.type = (user as any).type;
token.workspaceId = (user as any).workspaceId;
token.clientId = (user as any).clientId;

// session callback: token -> session, on every request
(session as any).type = token.type;
(session as any).workspaceId = token.workspaceId;
(session as any).clientId = token.clientId;

Enter fullscreen mode Exit fullscreen mode

これでアプリのすべての部分が1つの質問session.typeで、相手が誰かを正確に知ることができる。データクエリはトークンからのworkspaceIdまたはclientIdでスコープされ、クライアントがリクエストで送信するものから来ることは決してない。この最後の部分がすべてだ。テナント境界は、クライアントが改ざん可能なURLパラメータやリクエストボディではなく、署名付きトークンから来る。

それを強制するゲート

これらすべては、実際に人を締め出す1つの場所がなければ理論に過ぎない。このバージョンのNext.jsでは、ミドルウェアファイルはproxy.tsで、すべてのリクエストが通過する単一のゲートとなっている:

// 代理店ルートには代理店セッションが必要
if (pathname.startsWith("/agency")) {
  if (!session) return NextResponse.redirect(new URL("/login", req.url));
  if ((session as any).type !== "agency") {
    return NextResponse.redirect(new URL("/client/agents", req.url));
  }
  return NextResponse.next();
}

Enter fullscreen mode Exit fullscreen mode

このtype !== "agency"チェックが重要だ。ログインしたクライアントメンバーがアドレスバーに/agency/clientsと入力しても、500エラーや空白ページは表示されず、自分のポータルに弾かれる。この同じファイルはルートパスも分割する。アプリサブドメインではルートがダッシュボードまたはログインページに送り、マーケティングドメインではルートが公開ランディングページをレンダリングする。1つのファイル、4つの判断、すべて同じトークンで駆動される。

午後の時間を費やした落とし穴:エッジはデータベースを見ることができない

ここが誰かに教えてほしかった部分だ。ミドルウェアはエッジランタイムで動作する。エッジランタイムはbcryptをインポートできず、Prismaもインポートできない。試してみれば、ビルドは丁寧に失敗するのではなく、ミドルウェアの読み込みを試みた瞬間に失敗し、あたかも認証自体が壊れているかのように感じる。

解決策は、設定を2つに分割することだ。コールバック、ページ、セッション戦略、および空のproviders: []配列のみを保持するエッジセーフなauth.config.tsがある。Nodeからのインポートは一切行わない。次に、別途のauth.tsがこの設定をスプレッドし、bcryptとPrismaを使用した実際のCredentialsProviderを追加する。このファイルは、これらのインポートが合法なNodeランタイムでのみ実行される。

// auth.config.ts, edge safe, imported by proxy.ts
export const authConfig: NextAuthConfig = {
  session: { strategy: "jwt" },
  providers: [], // 実際のプロバイダーはauth.tsで追加
  callbacks: { /* jwt + session, Nodeインポートなし */ },
};

Enter fullscreen mode Exit fullscreen mode

ミドルウェアはエッジセーフな設定をインポートし、bcryptやデータベースに触れることなくトークンを読み取り検証できる。APIルートは完全なauth.tsをインポートする。最初に見たときには重複のように感じるかもしれない。しかしそうではない。「エッジで実行可能なコード」と「データベースが必要なコード」の境界を意図的に引いたものだ。

始める前に自分に言っていただろうこと

識別子フィールドは、1つの認証システムから2つのオーディエンスを提供する最も安価な方法であり、同時にコードベースの中で最も危険な行でもある。1つのtypeチェックの欠落は、特権昇格であり、見た目のバグではない。だから私はこれを不変条件として扱う。どこかで忘れられることを想定し、proxy.tsのゲートは、まさにページコンポーネントで忘れたことが侵害につながらないように存在している。

顧客に顧客がいるようなものを構築しているなら、これが目指すべき形だ。1つのプロバイダー、トークンに組み込まれた1つの識別子、それを強制する1つのゲート、そしてミドルウェアがデータベースをエッジに引きずり込むことなくIDを検証できるクリーンなエッジとNodeの分割。

完成品の動作を確認したい場合は、voice-dash.comにある。ただし、上記の認証モデルこそが私が実際に誇りに思っている部分であり、正しく動作させるのに最も時間がかかった部分だ。