Next.js + NextAuth5 で Magic Link(Resend) ログインする

## やりたいこと

今回の構成
- Next.js 15.0.0-rc.0
    - React 19.0.0-rc-f994737d14-20240522
- NextAuth 5.0.0-beta.19
- drizzele-orm 0.32.0 (drizzle-kit 0.23.0)
- PostgreSQL
- Resend

と、ちょっと攻めた？構成になっている。Next.js は Auth とは別でやりたいことがあったので v15 を使っているが、v14 でも同じだ。今回の説明に、v15 である意味はない。ORM も drizzle を使っているが、Prisma などでも良い。今回の主題は、NextAuth v5 だ。

やりたいこととしては、Next.js と NextAuth5 を使って、Magic Link（Resend）認証を行い、middleware にてアクセス制御を行いたい。Next.js は App Router、TypeScript で実装する。

middleware を使わないのであれば、非常に簡単に実装できるが、middleware を使おうとすると、とたんにエラーが出まくるので、それを解決する。

重要: 本記事は、rc, beta を使った実装なので、正式リリース時にこうなるとは限らない点にご留意ください。


### 先にソースコード

https://github.com/TAKT-R-D/resend-auth-sample

## セットアップ

Next.js が pnpm 推しのようなので pnpm でやることにする。

まずは、Next.js

```bash
pnpm create next-app@rc
```

全部デフォルトで
- TypeScript: Yes
- ESLint: Yes
- Tailwind CSS: No
- src/ directory: Yes
- App Router: Yes
- Turbopack: No
- custom alias: No

ver.14 の場合は、rc はいらない。


次に、DB, ORM 関係

```
pnpm add drizzle-orm postgres
pnpm add --save-dev drizzle-kit
```

つづいて、NextAuth 関連

```
pnpm add @auth/drizzle-adapter next-auth@beta
```

Next.js v14 以上を使う場合は、beta じゃないとダメ。

一応、node のバージョンを volta で固定しておく。

```
volta pin node@20.15.0
```

これで、package.json は以下のようになる。

```json:package.json
{
  "name": "resend-auth",
  "version": "0.1.0",
  "private": true,
  "scripts": {
    "dev": "next dev",
    "build": "next build",
    "start": "next start",
    "lint": "next lint"
  },
  "dependencies": {
    "@auth/drizzle-adapter": "^1.4.1",
    "drizzle-orm": "^0.32.0",
    "next": "15.0.0-rc.0",
    "next-auth": "5.0.0-beta.19",
    "postgres": "^3.4.4",
    "react": "19.0.0-rc-f994737d14-20240522",
    "react-dom": "19.0.0-rc-f994737d14-20240522"
  },
  "devDependencies": {
    "@types/node": "^20",
    "@types/react": "^18",
    "@types/react-dom": "^18",
    "drizzle-kit": "^0.23.0",
    "eslint": "^8",
    "eslint-config-next": "15.0.0-rc.0",
    "typescript": "^5"
  },
  "volta": {
    "node": "20.15.0"
  }
}
```

PostgreSQL は、Docker で立ち上げておく。

## ディレクトリ構成と主要なファイル

```
├── src
│   ├── app
│   │   ├── api/auth/[...nextauth]
│   │   │   └── route.ts
│   │   ├── dashboard
│   │   │   └── page.tsx         // 認証済みの時だけアクセスできるページ
│   │   └── page.tsx             // トップページ
│   ├── server                   // server 関連をまとめておく
│   │   ├── actions
│   │   │   └── resend-login.ts
│   │   ├── migrations/
│   │   ├── auth.config.ts
│   │   ├── auth.ts
│   │   ├── index.ts
│   │   └── schema.ts
│   └── middleware.ts
├── .env
├── package.json
└── drizzle.config.ts
```

## Drizzle ORM の設定

[Configuration Drizzle kit](https://orm.drizzle.team/kit-docs/config-reference) を参考に `drizzle.config.ts` を設定する

```ts:drizzle.config.ts
import { defineConfig } from "drizzle-kit";

export default defineConfig({
  schema: "./src/server/schema.ts",
  out: "./src/server/migrations",
  dialect: "postgresql",
  migrations: {
    prefix: "supabase",
  },
  dbCredentials: {
    url: process.env.DATABASE_URL as string,
  },
});
```

[Auth.js Drizzle ORM Adapter](https://authjs.dev/getting-started/adapters/drizzle) を参考に、schema の設定を行う。サンプルでは、`db` を export しているが、schema じゃないので、ここでは行わない。

```ts:src/server/schema.ts
import {
  boolean,
  timestamp,
  pgTable,
  text,
  primaryKey,
  integer,
} from "drizzle-orm/pg-core";
//import postgres from "postgres";
//import { drizzle } from "drizzle-orm/postgres-js";
import type { AdapterAccountType } from "next-auth/adapters";

/*
const connectionString = "postgres://postgres:postgres@localhost:5432/drizzle";
const pool = postgres(connectionString, { max: 1 });

export const db = drizzle(pool);
*/

export const users = pgTable("user", {
  id: text("id")
    .primaryKey()
    .$defaultFn(() => crypto.randomUUID()),
  name: text("name"),
  email: text("email").notNull(),
  emailVerified: timestamp("emailVerified", { mode: "date" }),
  image: text("image"),
});
(以下省略)
```

`account`, `authenticator`, `session`, `user`, `verificationToken` schema の設定になる。
削除した `db` の設定を別途行う。

```ts:src/server/index.ts
import { drizzle } from 'drizzle-orm/postgres-js';
import postgres from 'postgres';
import * as schema from '@/server/schema';
// for query purposes
const queryClient = postgres(process.env.DATABASE_URL!);
export const db = drizzle(queryClient, { schema });
```

PostgreSQL に反映する。

```bash
pnpm exec drizzle-kit generate
pnpm exec drizzle-kit push
```

`pnpm exec drizzle-kit studio` とコマンドすると、`https://local.drizzle.studio` が立ち上がり、ブラウザで見ることができる。

![Screenshot 2024-07-16 at 13.28.48.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/245908/e8f18ad5-fa01-98cb-e067-24204f0f2345.png)

## middleware のことを考えずに Rensed auth を実装する

まずは、`middleware` を考えずに実装する。

```ts:src/server/auth.ts
import NextAuth from "next-auth";
import Resend from "next-auth/providers/resend";
import { DrizzleAdapter } from "@auth/drizzle-adapter";
import { db } from "@/server";

export const { auth, handlers, signIn, signOut } = NextAuth({
  adapter: DrizzleAdapter(db),
  secret: process.env.AUTH_SECRET,
  trustHost: true,
  callbacks: {
    async session({ session, token }) {
      session.userId = token.sub ?? "";
      return session;
    },
    async jwt({ token, user }) {
      if (user) {
        token.sub = user.id;
      }
      return token;
    },
  },
  session: {
    strategy: "jwt",
    maxAge: 3 * 60 * 60,
  },
  providers: [
    Resend({
      from: process.env.RESEND_FROM,
    }),
  ],
});
```

```ts:src/app/api/auth/[...nextauth]/route.ts
import { handlers } from '@/server/auth';

export const { GET, POST } = handlers;
```

```text:.env
DATABASE_URL=postgres://johndoe:randompassword@127.0.0.1:5432/db
AUTH_SECRET=(YOUR_AUTH_SECRET)
AUTH_RESEND_KEY=(YOUR_RESEND_KEY)
RESEND_FROM=(YOUR_FRMO_EMAIL_ADDRESS)
```

`AUTH_SECRET` は、`openssl rand -base64 32` などで作る。`AUTH_RESEND_KEY` は、Resend の管理画面で設定する。Resend の設定は、Free プランではドメインは 1 つしか登録できないので、いったん localhost を設定しておき、DNS 等の登録が必要になり、すぐには反映されないが、それ以外で躓くところはないものと思われる。ので、省略。

これ（だけ）で、Resend Auth の実装は完了だ。`localhost:3000/api/auth/signin` にアクセスすると、

![Screenshot 2024-07-16 at 13.42.34.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/245908/11e95a0c-3096-33fa-cb11-494e49a81c95.png)

出来てる。ログインもできる。簡単がすぎる。

## middleware にいれる…

共通処理は middleware に入れておかないと不便なので、ただ入れてみる。
[Upgrade Guide(NextAuth.js v5)](https://authjs.dev/getting-started/migrating-to-v5) を見ると、NextAuth の config を auth.ts から auth.config.ts に分離し、それを middleware で読み込むような形で実装しろとあるので、そのようにする。

```ts:src/server/auth.config.ts
import type { NextAuthConfig } from "next-auth";
import Resend from "next-auth/providers/resend";

export default {
  secret: process.env.AUTH_SECRET,
  trustHost: true,
  callbacks: {
    async session({ session, token }) {
      session.userId = token.sub ?? "";
      return session;
    },
    async jwt({ token, user }) {
      if (user) {
        token.sub = user.id;
      }
      return token;
    },
  },
  session: {
    strategy: "jwt",
    maxAge: 3 * 60 * 60,
  },
  providers: [
    Resend({
      from: process.env.RESEND_FROM,
    }),
  ],
} satisfies NextAuthConfig;
```

```ts:src/server/auth.ts
import NextAuth from "next-auth";
import { DrizzleAdapter } from "@auth/drizzle-adapter";
import { db } from "@/server";
import authConfig from "./auth.config";

export const { auth, handlers, signIn, signOut } = NextAuth({
  adapter: DrizzleAdapter(db),
  ...authConfig,
});
```

```ts:src/middleware.ts
import NextAuth from "next-auth";
import authConfig from "@/server/auth.config";

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

export const config = {
  // https://nextjs.org/docs/app/building-your-application/routing/middleware#matcher
  matcher: ["/((?!api|_next/static|_next/image|.*\\.png$).*)"],
};
```

いざ！とアクセスしてみると、

```text
[auth][error] MissingAdapter: Email login requires an adapter.. Read more at https://errors.authjs.dev#missingadapter
```
というエラーが出る。ここ見ろというページに行ってもたいした情報は得られない。
`middleware` で読み込んでいる `authConfig` (`auth.config.ts`) に、Email login の場合は、`adapter` が必要で、それがない！と怒られている。でも… adapter 情報を、`auth.config.ts` に入れるとそれはそれでエラーになる。困る。GITHUB Auth などの場合は、問題にならないが、今回やりたいのは、Resend Auth。困った。

## 解決策: provider 情報を空にする

providers に Resend があるとエラーが出るのだから、空にしてしまえ。
ということで、あれこれ書き換える。

```ts:src/server/auth.config.ts
import type { NextAuthConfig } from "next-auth";

export default {
  secret: process.env.AUTH_SECRET,
  trustHost: true,
  callbacks: {
    async session({ session, token }) {
      session.userId = token.sub ?? "";
      return session;
    },
    async jwt({ token, user }) {
      if (user) {
        token.sub = user.id;
      }
      return token;
    },
  },
  session: {
    strategy: "jwt",
    maxAge: 3 * 60 * 60,
  },
  providers: [],
} satisfies NextAuthConfig;
```

```ts:src/server/auth.ts
import NextAuth from "next-auth";
import { DrizzleAdapter } from "@auth/drizzle-adapter";
import Resend from "next-auth/providers/resend";
import { db } from "@/server";
import authConfig from "./auth.config";

export const { auth, handlers, signIn, signOut } = NextAuth({
  ...authConfig,
  adapter: DrizzleAdapter(db),
  providers: [
    Resend({
      from: process.env.RESEND_FROM,
    }),
  ],
});
```

こんなんで本当に動くんかいな？と思うが、[Next.js Learn](https://nextjs.org/learn/dashboard-app/adding-authentication) でもやってるし、実際これで動く。

ここで、以下のようなエラーが出た場合は、Cookie を削除することで解決する。

```text
[auth][error] JWTSessionError: Read more at https://errors.authjs.dev#jwtsessionerror
[auth][cause]: JWEInvalid: Invalid Compact JWE
```

この実装には関係がないが、Resend でログインすると、`account` テーブルにレコードが作られない問題がある。issue が上がっているので、しばらくはウォッチしておくものとする。

[Resend email provider does not create an entry in Account table #10662](https://github.com/nextauthjs/next-auth/issues/10662)

## middleware で制限

`/dashboard` で始まるパスにはログインしていないとアクセスできないようにしたい。今回は簡単に `/` にリダイレクトすることにする。

```ts:src/middleware.ts
import NextAuth from "next-auth";
import authConfig from "@/server/auth.config";
import { NextRequest } from "next/server";

const { auth } = NextAuth(authConfig);
export default auth(async function middleware(req: NextRequest) {
  const isOnDashboard = req.nextUrl.pathname.startsWith("/dashboard");
  const authenticate = await auth();
  const isLoggedIn = !!authenticate?.user;
  if (isOnDashboard) {
    if (!isLoggedIn) return Response.redirect(new URL("/", req.nextUrl));
  }
});

export const config = {
  // https://nextjs.org/docs/app/building-your-application/routing/middleware#matcher
  matcher: ["/((?!api|_next/static|_next/image|.*\\.png$).*)"],
};
```
なんか auth, auth 嫌な感じだが…

## 検証

適当に トップページと dashboard ページを作る。
トップページは、ログインしていたらログアウトボタン、していなければログインフォームを置く。

```ts:src/app/page.tsx
import { auth, signOut } from "@/server/auth";
import SignIn from "@/app/components/SignIn";
import Link from "next/link";

export default async function Home() {
  const session = await auth();
  return (
    <main>
      <h1>HOME</h1>
      {session ? (
        <form
          action={async () => {
            "use server";
            await signOut({ redirectTo: "/" });
          }}
        >
          <button>Sign Out</button>
        </form>
      ) : (
        <SignIn />
      )}
      <div>
        Link to: <Link href="/dashboard">Dashboard</Link>
      </div>
    </main>
  );
}

```



![Screenshot 2024-07-16 at 15.30.51.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/245908/ac2a8c44-b605-015a-252e-b5368ddabb7d.png)

![Screenshot 2024-07-16 at 15.30.22.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/245908/e7bcfa63-59c1-a81b-6306-bffa491c5013.png)

それ以外のソースコードは省略。

全て意図した通りに動いた。エラーも出ない。Magic Link(Resend) の場合、Name 等の値は取得されないので、サイト上で表示させたいなどの場合は、name が null だったら… みたいな処理を別途追加する。middleware で、name がなかったら（初めてのユーザ）、name を入れてもらうと同時に規約に同意させるようなページにリダイレクトするようなことをすれば良さそう。

メール確認してね画面やメールは、デフォルトのままで良いってことはないんだろうから、実運用する場合は、そのあたりも変える必要がある。

## まとめ・余談

App Router を用いた Next.js で、NextAuth を使おうとすると、互換性のある ver.5 beta を使う必要があるが、活発に開発が行われているので、正式にリリースされる際には、もうちょっとスッキリするかも知れない。`next-auth@experimental` のドキュメントを見ると、ちょっと変わってるし。正式版リリースはもうすぐな気配だが、待ち遠しい。

なお、当初、GitHub Copilot に書かせようと思ってやり始めたが、RC, Beta なものは、GitHub Copilot からは出てこない。Next.js を書かせようとすると、Page Router を使いたがるし。そんな時は、ドキュメントを読んで解決すれば良い。ドキュメントを読むと言えば、公式ドキュメントを AI 翻訳した [Lang x Lang](https://langxlang.com)（今回参照したドキュメントは翻訳してないが…）を是非ご贔屓に！


### ソースコード
https://github.com/TAKT-R-D/resend-auth-sample