homehome

Auth.js + Next.js with Edge Runtime

Published

TocMenu目次

Next.jsに認証を実装できるライブラリにAuth.js(旧: NextAuth.js)があります。このライブラリを使用してEdgeランタイムで認証を処理しようとすると、例えば以下のようなエラーが起こります。

Server Error Error: Module not found: Can't resolve 'stream'

Edgeは軽量な実行環境のことです。この実行環境は、標準のWeb APIのみをサポートしているため、一部のNode.jsライブラリとは互換性がありません。このため、上記のようなエラーが発生します。

もう少し掘り下げてみましょう。

Auth.js + Next.jsのケース

Auth.jsのセッションストラテジーにデータベースを選択する場合は専用のDBアダプターを使用しますが、これはEdgeと互換性がありません。例えば、PostgresSQLはTCPソケット通信を行いますが、Edgeは前述のようにWeb標準APIがサポート範囲のため、TCPソケット通信を扱えません。また、仮にユーザセッションにJWTを選択した場合でも、メール認証(Magic Link)を実装する場合はDBアダプターが必要になるため、互換性がなくなります。メール認証の場合は、アダプターに加えてSMTPでユーザに認証用のメールを送信する場合、これも同様に互換性がなくなるはずです。

Edgeランタイムは、Next.js以外でも使用されていますが、Next.js(App Router)においてはミドルウェアがEdgeです。layout, page, ルートハンドラのデフォルト実行環境はNode.jsですが、以下の宣言をすればEdgeにすることができます。一方、ミドルウェアはNode.js環境に変更する手段はありません。

// change runtime
export const runtime = 'edge';

このコードを記述すると前述のようにAuth.jsは互換性の問題が発生する可能性が出てきます。Node.js環境でニーズが満たせている場合はそれでいいのですが、Edgeを使いたい場面もあります。また、ミドルウェアを使用する場合はランタイムを変更できないため、何かしらの対策が必要です。

この記事に書かれていること 🚀

この記事では、Next.jsのEdgeでAuth.jsを使用するための方法を探ります。コードは1から記述しませんが、既にEdge以外では認証が動作する状況を想定しています。ユーザ認証にはOAuthとMagic Link、ユーザセッションはdatabaseストラテジー、データベースにはMongoDB Atlasを使用しています。

Auth.jsはbeta版のv5(5.0.0-beta.19)を使用しています。v4までは初期化時のEdge互換がありませんでしたが、v5では互換性が確保されています(ややこしいことに、この記事で扱うDBアダプターの互換性とは異なります)。もしv4以前を使用している場合は、Upgrade Guideリファレンスをご覧ください。

エラーが起こる場面

前述のようにミドルウェアはEdgeランタイムのため、まずはミドルウェアを追加して冒頭に記述したようなエラーが起こるかを確認してみます。ミドルウェア上では、認証のチェックを行い特定のルートを保護することにします。

middleware.tsを作成します。

// src/middleware.ts

export { auth as middleware } from "@/app/api/auth/[...nextauth]/auth";

export const config = {
  // 定義したパス以外をミドルウェアの対象とする
  // https://nextjs.org/docs/pages/building-your-application/routing/middleware#matching-paths
  matcher: ["/((?!api|_next/static|_next/image|favicon.ico).*)"],
};

Auth.jsの構成ファイルは以下とします。前述のようにOAuth認証(GItHub)とMagic Linkによる認証、セッションの管理にMongoDBを使用し、認証用フォームは、独自の/authを設定しています。

// src/app/api/auth/[...nextauth]/auth.ts

import mongoClientPromise from "@/lib/database/mongoClientPromise";
import { MongoDBAdapter } from "@auth/mongodb-adapter";
import NextAuth from "next-auth";
import github from "next-auth/providers/github";
import NodemailerProvider from "next-auth/providers/nodemailer";

export const {
  handlers: { GET, POST },
  auth,
} = NextAuth({
  // OAuth & Magic links
  providers: [
    github,
    NodemailerProvider({
      server: process.env.EMAIL_SERVER,
      from: process.env.EMAIL_FROM,
    }),
  ],
  // db adapter
  adapter: MongoDBAdapter(mongoClientPromise, {
    databaseName: process.env.ENVIROMENT,
  }),
  // 独自のログイン・ログアウトページ
  pages: {
    signIn: "/auth",
    signOut: "/auth",
  },
  callbacks: {
    // 認可のたびに呼ばれる
    // https://authjs.dev/reference/nextjs#authorized
    authorized: async ({ auth, request: { nextUrl } }) => {
      const isLoggedIn = !!auth?.user;
      // 保護するルート
      const paths = ["/profile"];
      const isProtected = paths.some((path) =>
        nextUrl.pathname.startsWith(path)
      );
			// チェックとコールバック
      if (isProtected && !isLoggedIn) {
        const redirectUrl = new URL("/auth", nextUrl.origin);
        redirectUrl.searchParams.append("callbackUrl", nextUrl.href);
        return Response.redirect(redirectUrl);
      }
      return true;
    },
  },
});

callbacksのauthorizedではリクエストが「保護するルート」かつ「認証中か」をチェックしています。/profileへのアクセス時に未認証の場合はログインページの/authへリダイレクトします。尚、ミドルウェアのロジックを更に記述したい場合は、middleware.ts内に記述できます。

余談ですが、Next.jsのミドルウェアはmatcherにより厳密な適用ルートを定義できますが、現在のNext.js 14ではルートグループを条件にすることはできないようです。例えば、(routegroup)/:pathは不可です。これがサポートされれば、(public)/(protected)/のような保護の有無を分ける2つのディレクトリを作成し、matcherで(protected)/:pathを記述すればURLの構造を変えずに保護の適用をシンプルにできそうです。現状で同様のことをするには、protectedのようなディレクトリを作成するしかありません(URL構造が変わります)。

次に保護するprofileルートを作成します。

// src/app/profile/page.tsx

import { auth } from "@/app/api/auth/[...nextauth]/auth";

export default async function Page() {
  const session = await auth();

  return (
    <>
      <h2>Protected Route</h2>
      <p>{session?.user?.name ?? "no-name"}</p>
      <p>{session?.user?.email ?? "no-email"}</p>
    </>
  );
}

プロジェクトを起動すると、想定通り以下のようなエラーが確認できるはずです。

Error: The edge runtime does not support Node.js 'crypto' module.

保護するルートは/profileですが、ミドルウェアはmatcherで除外しているものを除き、他のルートでも読み込まれていることに注意が必要です。そしてこのミドルウェアは、Edgeと互換性のないMongoDBAdapterが記述されたauth.tsをインポートしています。これにより、すべてのページでエラーが発生します。ちなみにNodemailerとアダプターを削除すれば、アプリケーションは動作します。

ただし、これは使用するデータベースプロバイダーによって挙動がやや異なるようです。たとえば、Prismaを使用している場合は、クライアントv5.9.1以上かつjwtセッションを使用している場合は、ミドルウェアでデータベースクエリさえ行われなけば、EdgeでDBプロバイダーをインポートしても実行自体が落ちることはないようです。MongoDBの場合は、使用に問わずエラーが出ます。

Using Prisma with the jwt session strategy and @prisma/client@5.9.1 or above doesn’t require any additional modifications, other than ensuring you don’t do any database queries in your middleware.

Since @prisma/client@5.9.1, Prisma no longer throws about being incompatible with the edge runtime at instantiation, but at query time. Therefore, it is possible to import it in files being used in your middleware as long as you do not execute any queries in your middleware.

Prisma - Old Edge Workaround

以降は、これを解決していきます。

構成ファイルを分割する

Edgeへの対応手段はいくつかありますが、今回は構成ファイルを分割するという方法を用います。これは以前から使用されており、ドキュメントにも記載されていて一般的です。

構成ファイルを分けるというのは、Edgeの依存関係がないファイル(auth.config.ts)とEdge互換のあるファイル(auth.ts)に分けるということです。Edgeの互換性の問題が絡むのはデータベースアダプターのため、これを切り離します。そしてユーザセッションをjwtにします。

  • auth.config.ts(Edge対応)
  • auth.ts(Edge非対応)

ちなみに、デフォルトのユーザセッション(strategy)はjwtで、セッションはクッキーに保存されます。apapterを記述すると自動でdatabaseになりますが、明示的に記述すればjwtにすることができます。databaseの場合はセッションクッキーには、sessionTokenのみが含まれます。

まず、auth.config.tsを作成します。

// src/app/api/auth/[...nextauth]/auth.config.ts

import { NextAuthConfig } from "next-auth";
import github from "next-auth/providers/github";

export const authConfig = {
  providers: [github],
  callbacks: {
    authorized: async ({ auth, request: { nextUrl } }) => {
      const isLoggedIn = !!auth?.user;
      const paths = ["/profile"];
      const isProtected = paths.some((path) =>
        nextUrl.pathname.startsWith(path)
      );

      if (isProtected && !isLoggedIn) {
        const redirectUrl = new URL("/auth", nextUrl.origin);
        redirectUrl.searchParams.append("callbackUrl", nextUrl.href);
        return Response.redirect(redirectUrl);
      }
      return true;
    },
  },
} satisfies NextAuthConfig;

これは単なるオブジェクトです。auth.tsからEdgeと依存関係のないprovidersとcallbacksを移動しています。

auth.tsは以下です。

// src/app/api/auth/[...nextauth]/auth.ts

import mongoClientPromise from "@/lib/database/mongoClientPromise";
import { MongoDBAdapter } from "@auth/mongodb-adapter";
import NextAuth from "next-auth";
import { authConfig } from "./auth.config";

import NodemailerProvider from "next-auth/providers/nodemailer";

export const {
  handlers: { GET, POST },
  auth,
} = NextAuth({
  ...authConfig,
  adapter: MongoDBAdapter(mongoClientPromise, {
    databaseName: process.env.ENVIROMENT,
  }),
  pages: {
    signIn: "/auth",
    signOut: "/auth",
  },
  providers: [
    ...authConfig.providers,
    NodemailerProvider({
      server: process.env.EMAIL_SERVER,
      from: process.env.EMAIL_FROM,
    }),
  ],
  session: { strategy: "jwt" },
}

auth.config.tsに移動した残りに、auth.config.tsをインポートしています。更にauth.config.tsはadapterが使えない(DB操作ができない)ため、auth.tsではsession管理をjwtに変更します。こうすることでアカウントはDBを保持しつつ、セッション管理はJWTを使用して行うことができるようになります。

また、providersにはNodemailerが残っていますが、auth.config.ts側のgithubも維持したいため、スプレッド演算子を使用して構成をマージしています。これを行われないとauth.config.ts側のprovidersが上書きされてしまいます。

最後に、ミドルウェアファイルを修正する必要があります。

// src/middleware.ts

import { authConfig } from "@/app/api/auth/[...nextauth]/auth.config"; //fix
import NextAuth from "next-auth";

export const { auth: middleware } = NextAuth(authConfig);

export const config = {
  matcher: ["/((?!api|_next/static|_next/image|favicon.ico).*)"],
};

以前はauth.tsをインポートしていましたが、auth.config.tsを使用するように変更しました。auth.config.tsはEdgeと互換性があるため、ミドルウェアで読み込んでも問題ありません。

実行するとアプリケーションはクラッシュしないはずです。また、認証後にデータベースを確認すると、セッションはJWTを使用しているため、sessionsコレクションは使用されなくなっているのが確認できます。

余談: Edge and Email Provder

上記で完成しており、ここからは検証実験のような内容です。そして、少しややこしいです。

先ほどの凡例では、auth.config.tsにgithub OAuth、auth.tsにNodemailerを記述しています。このNodemailerはauth.config.tsに移動するとEdgeと互換性がないため、ミドルウェアで読み込まれ起動時にアプリは落ちます。構成ファイルこそ分割していますが、冒頭のエラー確認と同様のため当然の結果です。Nodemailerは標準でSMTPトランスポートを使用するのが原因か思われます。

Error: The edge runtime does not support Node.js 'stream' module.

eval node_modules/nodemailer/lib/base64/index.js (56:1)
(middleware)/./node_modules/nodemailer/lib/base64/index.js

では、このメール送信部分を担うNodemailerをEdgeと互換のある別の構成に変更するとどうでしょうか?Magic LinkはHTTPにも対応しており、HTTPはEdgeと互換性があります。

これは理論上、動作するはずです。しかし、(煩雑になるので)結果からを書くと、現状のAuth.jsでは完全には機能しません。その理由は末尾に記載していますが、以降はこれを確認していきます。

Email Provider with HTTP

ここまでの説明ではメールを担うサービスについては明記していませんでしたが、SMTPはmailtrap(メール配信用のプラットフォーム。ローカル開発時に役立つ無料のテスト枠があります)を使用していました。利用するサービスにもよりますが、通常はHTTPベース(API)でのリクエストもサポートされているはずです。mailtrapもHTTPに対応しています。このため、以降はmailtrapを作例にしています。mailtrapのAPIは以下をご覧ください。

まず環境変数を定義します。SMAPで送信する場合はsmtp://から始まる接続パラメータが必要でしたが、HTTPの場合はシークレット(EMAIL_SEXRET)とID(EMAIL_TEST)が必要です。シークレットは、MyInboxを開きAPIのタブをクリックすると表示されるCredentialsに記載されています。IDは使用するInboxを選択したときにURLから確認できます。https://mailtrap.io/inboxes/<ID>/messages<ID>の値を使用します。

これらを.env.localに設定します。

# mailtrap
EMAIL_FROM=noreply@example.com
EMAIL_SECRET=your-secret
EMAIL_TEST=your-test-number

そして、rootにtypes/email.d.tsというファイルを作成し、インターフェイスを定義します。

// types/email.d.ts

declare interface SendVerificationRequestParams {
  identifier: string;
  url: string;
  expires: Date;
  provider: EmailConfig;
  token: string;
  theme: any;
}

declare type Awaitable<T> = T | PromiseLike<T>;

declare interface EmailConfig {
  // defaults
  id: "email";
  type: "email";
  name: "Email";
  server: string;
  from: string;
  maxAge: number;
  sendVerificationRequest: (
    params: SendVerificationRequestParams
  ) => Awaitable<void>;
  options: any;
  secret?: string;
}

auth.config.tsに新しくメールプロバイダー追加します。

// src/app/api/auth/[...nextauth]/auth.config.ts

import { NextAuthConfig } from "next-auth";
import github from "next-auth/providers/github";
import { Provider } from "next-auth/providers";

export const authConfig = {
  providers: [
    github,
    {
      id: "mailtrap",
      type: "email",
      async sendVerificationRequest({
        identifier: email,
        url,
      }: SendVerificationRequestParams) {
        // クラウドメールプロバイダーAPIを呼び出してメールを送信する
        const response = await fetch(
          `https://sandbox.api.mailtrap.io/api/send/${process.env.EMAIL_TEST}`,
          {
            // bodyは使用するプロバイダによって異なる。
            body: JSON.stringify({
              from: {
                name: "Mailtrap Text",
                email: `${process.env.EMAIL_FROM}`,
              },
              to: [
                {
                  email,
                },
              ],
              subject: "You are awesome! (sign in to app)",
              html: `<html><head></head><body><p>Click to <a href="${url}">login</a></p></body></html>`,
            }),
            headers: {
              Authorization: `Bearer ${process.env.EMAIL_SECRET}`,
              "content-type": "application/json",
              accept: "application/json",
            },
            method: "POST",
          }
        );

        if (!response.ok) {
          const { errors } = await response.json();
          throw new Error(JSON.stringify(errors));
        }
      },
    } as unknown as Provider,
    //...
}

sendVerificationRequestでは、fetchを使用してクラウドメールプロバイダーAPIを呼び出します。引数のemailは後ほど記述する組み込みのsignIn()から提供されます。

bodyは利用するサービスにより異なりますが、from(差出人), to(宛先), subject(件名), html(HTML形式の本文)を定義しています。mailtrapで利用できるテンプレートはここで確認できます。

次にauth.tsを修正します。単にprovidersを削除するだけです。

// src/app/api/auth/[...nextauth]/auth.ts

import mongoClientPromise from "@/lib/database/mongoClientPromise";
import { MongoDBAdapter } from "@auth/mongodb-adapter";
import NextAuth from "next-auth";
import { authConfig } from "./auth.config";

export const {
  handlers: { GET, POST },
  auth,
} = NextAuth({
  ...authConfig,
  adapter: MongoDBAdapter(mongoClientPromise, {
    databaseName: process.env.ENVIROMENT,
  }),
  pages: {
    signIn: "/auth",
    signOut: "/auth",
  },
  session: { strategy: "jwt" },
}

最後にメール認証フォームで送信時のハンドラ関数を以下のように修正します。

// src/components/LoginForm.tsxの抜粋

const handleEmailSignIn = async (e: FormEvent<HTMLFormElement>) => {
	e.preventDefault();
  await signIn("mailtrap", { email, callbackUrl: "/protected" });
};

signIn()の第1引数は追加したプロバイダーで定義したidです。この値は、ログインするプロバイダーを指定します。Nodemailerのときはnodemailerを指定しているはずですが、ここでは新しく構成したプロバイダーを呼び出すために変更が必要です。

すべてが揃ったので開発サーバを立ち上げてみます。

oops!

[auth][error] MissingAdapter: Email login requires an adapter.. 
Read more at https://errors.authjs.dev#missingadapter

コンソールを見るとアダプターのエラーが出ています。ただし、ここまでのエラーと異なりアプリケーションはクラッシュせず動作します。GitHubとMagic Mailでユーザ登録を行うと、MongoDB内のaccountやuserコレクションに登録したユーザが保存されているのも確認できます。

ただし、ミドルウェアで保護している/profileに飛ぶと、callbacksのauthorized内でauthが取得できません。auth変数を書き出すと、以下のようなエラーが確認できます。

{
  message: 'There was a problem with the server configuration. Check the server logs for more information.'
}

これはアサーションプロセスのバグのようです。

Thanks for pointing this out, it looks liek this is a bug in the assertion process (checking to make sure yuo've added a database adapter) when using an email adapter and also doing the edge-runtime split config.

Basically, it's checking in the edge runtime (where you don't have the adapter in the auth.js config), to ensure that the provider (resend) can operate correctly. But actually, we're not sending any emails in the middleware (edge runtime) anyway, so we can delay that checking to only happen in places where we would actually be sending an email (like in the normal serverles functions / backend / API route).

issuecomment-2081433876

要は、DBプロバイダーのチェックを構成時に行っているのが原因のようです。

現状での解決には以下が示されています。

For now, since the Email provider actually isn't doing any work in the middleware / edge runtimes where its throwing this error, you can move your Email provider to the config where you have your adapter as well, so they both don't get included in the middleware / edge runtime enviornments.

That should avoid this error and still have your Resend / whatever Email provider be able to send emails and use up verificationTokens upon signin 👍

issuecomment-2082875544

元も子もないですが、auth.tsにメールプロバイダーを移動させろということです。

Mail Providerをauth.tsに移動する

解決方法に従い、修正を行っていきます。

まず、auth.config.tsのprovidersのメールプロバイダを削除します。

// src/app/api/auth/[nextauth]/auth.config.ts

import { NextAuthConfig } from "next-auth";
import github from "next-auth/providers/github";

export const authConfig = {
  providers: [
    github,
    // delte mail provider
  ],
  //...
} satisfies NextAuthConfig;

auth.tsにメールプロバイダを定義し、auth.cofnig.tsのprovidersとマージします。

// src/app/api/auth/[...nextauth]/auth.ts

import mongoClientPromise from "@/lib/database/mongoClientPromise";
import { MongoDBAdapter } from "@auth/mongodb-adapter";
import NextAuth from "next-auth";
import { authConfig } from "./auth.config";
import { Provider } from "next-auth/providers";

export const {
  handlers: { GET, POST },
  auth,
} = NextAuth({
  ...authConfig,
  //...
  // fix
  providers: [
    ...authConfig.providers,
    {
      id: "mailtrap",
      type: "email",
      //...
    }
  ],
});

Nodemailerのときと同じことをしているので当たり前なのですが、これで問題なく動作します。

現状はこの方法をとるしかないと思われます。

おわりに

今回は作例としてミドルウェアでルートを保護しました。ただし、ルートでのセッションをミドルウェア頼りにするのはよろしくないようです。Auth.tsのドキュメントに以下のように記載されています。

You should not rely on middleware exclusively for authorization. Always ensure that the session is verified as close to your data fetching as possible.

認証をミドルウェアだけに頼るべきではありません。常に、データ取得のできるだけ近くでセッションが検証されるようにしてください。

layoutもしくはpageでセッションをチェックしましょう。

const session = await auth();

if (!session) {
	redirect("/login");
}

Auth.js v5はベータ版のため、今回記述した内容は時間の経過により動作や記述方法が変わる可能性があります。ドキュメントはv5で整備され始めているため、最新情報は公式をご覧ください。