NextAuth Next.js TypeScript OIDC 設計

後付け可能な認証を NextAuth で設計する — ログイン機構が決まらないまま、ログイン前提のプロダクトを作った話

NextAuth Next.js TypeScript OIDC 設計

はじめに

こんにちは。ロリポップ・ムームードメイン事業部でエンジニアリングリードをしています kinosuke01 といいます。

「この機能はログインしたユーザーのものとして扱いたい」というのは、ほとんどのプロダクトで当たり前の要件となります。ところが、プロダクト本体の開発を進めたいタイミングで、ログインの仕組みがまだ決まっていないという状況に直面することがあります。

後から差し替え可能にしておくというのは一つの手です。しかし「あとで差し替え」を甘く見ていると、いざ差し替えるときに思いのほか大がかりな書き換えが発生してしまう場合もあるのではないでしょうか。

この記事では、AIサイトエージェント というプロダクトの開発で実際に直面したこの状況と、そこで取った方針について紹介していきます。

要点を先にまとめると、以下の一点になります。

決まっていない領域を、差し替え可能なレイヤーに封じ込める。そのレイヤーだけを「本物と同じ形の偽物」で置き、他のコードからは本物と区別できない状態で先に作り切る。

具体的には、NextAuth.js を土台にした「本物と同じ形の偽物ログイン」を用意することで、後から本番のログイン機構(OIDC)に NextAuth インスタンスの差し替えだけで移行できるようになりました。以降の節で、この構造を順に分解していきます。

前提:AIサイトエージェントとは

本題に入る前に、舞台となるプロダクトの輪郭を簡単に共有しておきます。

AIサイトエージェントは、「カフェのサイトを作りたい」「フリーランスのポートフォリオが欲しい」といった自然言語の指示を投げると、ページ構成・デザインテーマ・コンテンツまでを一括で生成してくれる Web サイト制作サービスです。生成したあとも、チャット越しに「トップのキャッチを変えて」「このセクションの写真を差し替えて」と伝えれば、AI が編集を代行してくれます。

img01 img02

このサービスは、ロリポップ!レンタルサーバームームードメイン のどちらからも利用できるようになっています。ロリポップのユーザーとムームードメインのユーザー、それぞれが AIサイトエージェントのコンパネに入ってサイトを作れる、というのが本番のユースケースとなります。

ロリポップ/ムームードメインのアカウントでOIDCログインする構成

技術スタック

この記事のコード例を読む前提として、プロダクトの技術スタックにも軽く触れておきます。

以降の本文では NextAuth の authorize / jwt / session といったコールバック関数がコード例に登場します。NextAuth に馴染みのない方は、「ログイン処理の各ステップで呼ばれるフック関数」程度のざっくりした理解で読み進めていただければ大丈夫です。

課題の整理:ログインが決まっていない中で何を作るか

AIサイトエージェントは、最終的にはロリポップとムームードメインの2つのサービスのアカウントでログインできる形に落ち着きました。しかし、はじめからそう決まっていたわけではなく、アカウント基盤をどうするか・どう認証するかは、ビジネス・技術の両面から議論が続いている状況でした。

一方で、サイトを生成・編集するというプロダクトの中核機能の開発を止めて待つ、という選択肢はありませんでした。Webサイトの生成、ユーザーによる編集、自分のサイトにだけアクセスできる仕組みといった機能は、どれも「ログイン済みユーザーがいる」ことを前提にしています。つまり、ログイン機構が決まっていないまま、ログイン前提の機能を作り進める必要があるという状況になっていました。

この状況から、解くべき課題は次の2つに分解できます。

  1. 今、ログイン前提の機能をどう作るか
  2. 本番のログイン機構が決まったとき、どうシームレスに繋ぎ込むか

1.だけなら、サーバー側関数で固定の userId を返すような簡易なモックで十分です。しかし 2. を同時に成立させようとすると、それだけでは足りません。本物のログインが乗ったときに、セッションの持ち方もユーザーのテーブル構造もごっそり変わるとなると、結局そのタイミングで広範囲の書き換えが発生してしまいます。

加えて、設計上もうひとつ大きな制約がありました。それが次の 独立開発の要件 です。

開発環境を外部サービスから独立させたい

この時点では本番のログイン機構がまだ決まっていない、というのは先に述べたとおりです。ただし、アカウント基盤の候補として議論されていたロリポップやムームードメインといった既存サービスは、どちらも長く運用されている巨大なコードベースを持つサービスとなります。仮にこうした既存サービスのアカウント基盤と繋ぎ込むことが決まった場合、それら本番と同等のログイン基盤をまるごとローカル開発に繋ぎ込むというのは現実的ではありません。セットアップの手間もさることながら、外部依存が増えるほど開発体験は悪くなっていきます。

そのため、本番のログイン機構が最終的に何になっても困らないよう、認証の外部依存ゼロでプロダクト本体を動かせる ことも考慮したいと考えていました。

方針

ここまでを踏まえて、方針は次のように定めました。

「本物が来たときに差し替えるレイヤー」だけを偽物にする。それ以外は、本番運用で使うものと同じ構造で作り込む。

具体的には、以下の3点を本物と同じ形で作り込むことにしました。

  • セッション管理の仕組み: JWT ベースのセッション、コールバックの流れ
  • ユーザーのテーブル構造: 外部認証プロバイダーとの紐付けを前提にしたスキーマ
  • JIT プロビジョニング: 初回ログイン時にユーザーと所属組織を作成する流れ

偽物にするのは「認証のやり方そのもの」だけとなります(以降、この偽物の認証を モック認証 と呼びます)。これなら、認証のやり方が決まったときに、そこだけ差し替えれば済むようになります。

モック認証に求める振る舞い

実装の話に入る前に、このモック認証にどんな挙動をさせたいのかを具体化しておきます。「本物と同じ形」と言っても曖昧ですので、期待する振る舞いをあらかじめ言語化しておくと、以降の実装がなぜそうなっているのかが見えやすくなります。

ここで puid(provider user id の略。プロバイダー側のユーザー識別子に相当する値)という言葉が出てきます。本番では OIDC プロバイダーから渡ってくる sub クレームですが、モックでは開発者が自由に指定できる文字列として扱います。以降の節でも繰り返し登場する語となります。

求める振る舞いは、次のように整理できます。

モック特有の部分(偽物としての振る舞い)

  1. 任意のIDでログインできる: ログイン画面で puid を自由に入力でき、未指定の場合は固定のデフォルトユーザーとしてログインできる
  2. 外部サービスへの問い合わせは発生しない: OIDC の認可エンドポイントや userinfo を呼ばない。入力された puid を信頼してログイン完了とする(前節の独立開発要件に対応)
  3. 初回ログイン時にユーザーを自動生成する: その puid に対応する DB レコードが無ければ、ユーザー・組織・ExternalIdentity を自動で作る(JIT プロビジョニング)

本番と揃えたい部分(アプリから見て本物と同じ形)

  1. セッションから得られる情報は本番と同じ: appUserId, providerUserId, provider がセッションに揃い、アプリケーションコードから見ると 本番認証と区別がつかない状態となる

開発体験の要件

  1. puid を変えればユーザーを切り替えられる: 権限・所有権など複数ユーザーが絡む検証を、開発中も素直に試せる

モック認証のログインフォーム

任意のユーザー名を入力すると、そのユーザーでログインできる

1〜3 が偽物として置く部分、4 がアプリに向けてそろえる部分、5 が開発者が使うときの体験です。では、この3つの層をどう実装していくか、順に見ていきましょう。

土台として NextAuth を選ぶ

この設計を支える土台として NextAuth を採用しました。NextAuth は Provider という概念を中心に、Credentials(任意の独自認証)、OAuth、OIDC など複数の認証方式を同じ抽象のもとに扱えるフレームワークです。

「モック認証も一つの Provider として扱い、本番の OIDC 認証も同じく Provider として差し替える」という構造が、そのまま課題にフィットしました。セッション管理やコールバックの組み立て方は Provider に依存しないので、モック時に書いたコールバック処理は OIDC 導入後もそのまま使い回せます。このことが、後述する差し替え時の手数の少なさに直結しました。

テーブル構造は「外部連携前提」にしておく

ログインがモックであっても、テーブル構造はあとで使う本物のスキーマを想定して設計しました。ポイントは、ユーザー本体(User)と外部認証プロバイダーとの結び付け情報(ExternalIdentity)をテーブル分離し、プロバイダー種別をユニーク制約に含めたところとなります。

model ExternalIdentity {
  userId         String       @unique
  provider       AuthProvider
  providerUserId String
  // ... 他のカラム省略

  @@unique([provider, providerUserId])
}

enum AuthProvider {
  MUUMUU
  LOLIPOP
  MOCK
}

モック認証もれっきとした「プロバイダーの一つ」として AuthProvider.MOCK を割り当てることで、本番のプロバイダーと同じ経路でユーザーを特定できるようになります。本番のプロバイダーが後から増えても、enum に値を足して ExternalIdentity を作るだけで対応可能です。モックと本番を同じ構造で受け止めるスキーマとなっています。

モック認証を NextAuth の上に載せる

モック認証は NextAuth の Credentials Provider で実装しました。本物の認証ではなく、画面で入力された puid をそのまま通すだけのダミーです。

// 入力された puid を簡易バリデーションするための正規表現
const PUID_PATTERN = /^[a-zA-Z0-9-]+$/;

function createMockAuth() {
  return NextAuth({
    providers: [
      Credentials({
        id: "credentials",
        name: "Mock Login",
        credentials: {
          puid: { label: "ProviderUserId", type: "text" },
        },
        async authorize(credentials) {
          const puid =
            typeof credentials?.puid === "string" &&
            credentials.puid.trim() !== ""
              ? credentials.puid.trim()
              : "mock-user";

          if (!PUID_PATTERN.test(puid)) {
            return null;
          }

          // jwt コールバックで user として受け取る値
          return { id: puid };
        },
      }),
    ],
    trustHost: true,
    callbacks: {
      async jwt({ token, account }) {
        return mockJwtCallback({ token, account });
      },
      async session({ session, token }) {
        return commonSessionCallback({ session, token }); // 本番と共通
      },
    },
    pages: { signIn: "/login" },
  });
}

コードの中に authorize / jwt / session という3つのコールバックが出てきます。NextAuth に馴染みのない方向けに、それぞれの役割と、mock-user でログインボタンを押したときの呼び出し順を軽く整理しておきます。

  • authorize: ログインフォームから送られた値を受け取り、認証の可否を判断するコールバックです(Credentials Provider 特有のもの)。OK ならユーザーを表すオブジェクトを返し、NG なら null を返します。
  • jwt: セッションの元となる JWT を組み立てるコールバックです。authorize が返した情報をベースに、JWT へ積みたい情報(ここでは appUserIdproviderUserId など)を追加できます。
  • session: アプリケーション側で auth() から取り出すセッションオブジェクトを組み立てるコールバックです。JWT の中身を、アプリに見せたい形へ整えるのが役割です。

ログイン画面で puid に mock-user を入力してログインボタンを押した場合、これらは次の順で呼ばれます。

  1. authorize({ puid: "mock-user" }): puid を検証し、{ id: "mock-user" } を返す
  2. jwt: authorize が返した情報をもとに、JIT プロビジョニングで DB からユーザーを取得(なければ作成)し、appUserId などを JWT に積む
  3. session: JWT の値をセッションに詰め替え、アプリから参照できる形に整える

この流れを頭に入れておくと、以降のコールバックの中身が読みやすくなります。では、authorize は任意の puid を受け取ってそのまま通すだけなので、キモとなるのは残りの2つです。session コールバックは本番と共通のものを使っており、jwt コールバックもモックと本番で中の処理(後述する JIT プロビジョニングの有無)こそ違うものの、JWT に積む情報の形(providerUserId, appUserId, provider)は揃えてあります。

JIT プロビジョニングで複数ユーザーに対応する

開発中は「ユーザーAとしてログインして確認」「ユーザーBとしてログインして権限を確認」といったシナリオが頻繁に発生します。モックだからといって単一ユーザー固定にしてしまうと、そうした検証がやりにくくなってしまいます。

そこで、JWT コールバックで JIT(Just-In-Time)プロビジョニング を行うようにしました。ログイン時に指定された puid がまだ DB に存在しなければ、そのタイミングでユーザーと所属組織を作成します。

async function mockJwtCallback({ token, account }) {
  if (!account) {
    return token;
  }

  const providerUserId = token.sub as string;

  // (provider, providerUserId) で既存ユーザーを探し、なければ
  // ユーザー・組織・組織メンバー・ExternalIdentity をトランザクション内で一括作成する
  const appUser = await userService.getOrCreateUserByExternalIdentity(
    AuthProvider.MOCK,
    providerUserId,
  );

  return {
    ...token,
    providerUserId,
    appUserId: appUser.id,
    provider: AuthProvider.MOCK,
  };
}

セッションコールバックを本番と共通化する

さて、この記事のキモとなるのが次の commonSessionCallback です。モックと本番で 完全に同じ関数 を使い回すことで、「セッションから取り出せる値の形」がモードに依らず一定です。

async function commonSessionCallback({ session, token }) {
  // jwt コールバックで積んだ error をそのまま伝搬
  if (token.error) {
    session.error = token.error as string;
    return session;
  }

  // DB 側でユーザーが削除・無効化されていないかを確認
  const validation = await validateUserExists(
    token.appUserId as string | undefined,
  );
  if (!validation.isValid) {
    session.error = validation.error;
    if (validation.error === "ActiveUserNotFound") {
      session.appUserId = undefined;
    }
    return session;
  }

  session.providerUserId = token.providerUserId as string | undefined;
  session.appUserId = token.appUserId as string | undefined;
  session.provider = token.provider as AuthProvider | undefined;
  return session;
}

中身はほぼ JWT からセッションへの値の詰め替えだけで、モック・本番の差は一切ありません。「ログイン済みユーザーを DB で再確認する」というプロダクト側の要件だけを淡々と満たす形となっています。この関数がモードに依存しない形で書けていることが、後の差し替えコストを最小にしてくれます。

セッション型も同じ形で固定しておきます。

declare module "next-auth" {
  interface Session {
    providerUserId?: string;
    appUserId?: string;
    provider?: AuthProvider;
    error?: string;
  }
}

アプリケーションのコードは、このセッションから session.appUserId を取り出して使うだけです。モックか本番かを意識する必要がありません

export async function verifyWebsiteOwnership(websiteId: string) {
  const session = await auth();
  if (!session?.appUserId) {
    throw new UnauthorizedError();
  }

  const website = await websiteService.findByIdForUser(
    websiteId,
    session.appUserId,
  );
  if (!website) {
    throw new NotFoundError("Website not found");
  }
}

このガード関数が、モック時代も本番移行後もそのまま動いているというのが、この設計のポイントとなります。

本番ログインが決まった後の差し替え

その後、「ロリポップとムームードメインのアカウントで OIDC ログインする」という方針が確定しました。OIDC は NextAuth が標準でサポートしているので、Provider 設定を書くだけで基本的には動くようになっています。

function createOidcAuth() {
  const providers = [
    createOidcProviderConfig("muumuu", "Muumuu Domain", "MUUMUU"),
    createOidcProviderConfig("lolipop", "Lolipop", "LOLIPOP"),
  ].filter((p) => p !== null);

  return NextAuth({
    providers,
    trustHost: true,
    session: {
      strategy: "jwt",
      maxAge: 24 * 60 * 60,
      updateAge: 60 * 60,
    },
    callbacks: {
      signIn: oidcSignInCallback,
      jwt: oidcJwtCallbackWithProviderGuard,
      session: commonSessionCallback, // モック時代と同じもの
    },
    pages: { signIn: "/login" },
  });
}

重要なのは、session コールバックはモックと共通のものを使っているところです。セッションから取り出せる値(appUserId, provider, providerUserId)の形は変わっていないので、アプリ本体のコードはほぼそのままで動きました。OIDC 導入に伴う変更は、認証レイヤー内のファイル(新設した OIDC 用のコールバック群と起動時の分岐)にとどまり、ガード関数や Server Action などアプリ側の呼び出しコードは変更不要となっています。

JWT コールバックだけは OIDC 用に差し替わります。ムームー/ロリポップの OIDC では「契約 API 側で事前に作成済みのユーザーに対してログインを許可する」というルールとなっているため、getOrCreateUserByExternalIdentity(なければ作る)ではなく getUserByExternalIdentity(見つからなければログイン拒否)を使う点がモックとの違いとなります。

モードの切り替えは、どちらのファクトリ関数を呼ぶかを差し替えるだけとなっています。

// OIDC モードで起動する場合
const nextAuth = createOidcAuth();

// モック認証モードで起動する場合
const nextAuth = createMockAuth();

export const { handlers, auth, signIn, signOut } = nextAuth;

本番コードの書き換えはほぼこの一カ所で済み、シームレスな差し替えが実現できました。

副産物:モック認証が開発環境に残り続けている

最初から狙っていたこととはいえ、本番ログインが決まったあとも開発環境向けにモック認証モードを残せているというのは、実際に開発を回すうえで効いています。ローカル開発で puid を切り替えながら複数ユーザーのシナリオを試せるので、「差し替えのためのモック」がそのまま「開発体験のためのモック」として現役で動き続けている状態となります。

まとめ

決まっていない領域があるとき、「あとで差し替える」を本当にシームレスにやるには、本物と同じ形の偽物を用意するというのが有効なアプローチになります。ポイントを振り返ります。

  • 差し替えるレイヤーを明確にする: 認証のやり方だけを偽物にし、セッション・テーブル構造・JIT プロビジョニングは本番と同じ形で作り込む
  • 抽象の提供者を借りる: NextAuth のように「複数の認証方式を同じ抽象で扱う」ことが前提のフレームワークを土台にすると、差し替えが自然な操作となる
  • モックも本番と同列に扱う: AuthProvider.MOCK のように、モックを本番プロバイダーと同じ型・経路の上に載せることで、設計がぶれない

ログイン機構に限らず、要件が決まりきっていない依存を抱えたままプロダクトを前に進めたい場面は、開発の現場には少なくありません。そういうときは、決まっていない部分を特定のレイヤーに封じ込めつつ、それ以外は本番の設計で作り込む、という方針が有効です。どこまでを偽物で受け、どこから先は本番と同じ形で作るか、その線引きの設計こそが、不確実性を抱えたまま前に進むための要となります。