【Next.js 13 + Next-Auth + prisma + postgresql】appRouterを使用したJWT認証とリダイレクト処理の実装

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2822425/01803f20-5295-727f-cd03-b2d7d6cf17ea.png)
NextAuthは、Next.jsアプリケーションに認証を追加するためのライブラリです。

OAuth、Email、JSON Web Tokens (JWT)など、多くの認証プロトコルをサポートしています。Google、GitHub、Twitterなど、多数のプロバイダから直接サインインできます。


本プロジェクトの構造は以下のようになります。
ソースコードはこちらです
https://github.com/shalXXXX/prisma_jwt
```
app/
├── api
│   ├── auth
│   │   └── [...nextauth]
│   │       └── route.ts
│   ├── login
│   │   └── route.ts
│   ├── register
│   │   └── route.ts
│   └── type.ts
├── components
│   └── Provider.tsx
├── globals.css
├── layout.tsx
├── options.ts
├── page.tsx
├── signIn
│   ├── SignIn.tsx
│   └── page.tsx
├── signUp
|   ├── SignUp.tsx
|   └── page.tsx
prisma/
├── migrations
├── schema.prisma
├── seed.ts
global/
└── db.ts
middleware.ts
```

# Next.jsプロジェクトの作成
以下のコマンドで新たなNext.jsプロジェクトを作成します。
```
$ npx create-next-app@latest --experimental-app
```
プロジェクト名を決めて、その他の項目はデフォルトのままで大丈夫です。

# データベースの設定
今回はdockerでpostgresqlサーバを構築して使用します。
以下のようにdocker-compose.ymlを作成してください。
environmentの各項目は.envファイルの中に記述するなどして安全に保存してください。
```docker-compose.yml
version: '3.8'

services:
  db:
    image: postgres:13
    volumes:
      - ./data:/var/lib/postgresql/data
    environment:
      POSTGRES_USER: user
      POSTGRES_PASSWORD: password
      POSTGRES_DB: nextjsdb
    ports:
      - 5432:5432
```

作成したら、dockerコンテナを立ち上げます。
```
$ docker-compose up -d --build
```
# Prismaの設定
## パッケージのインストール
まず、必要なパッケージをインストールします。
```
$ npm install @prisma/client @auth/prisma-adapter
$ npm install prisma --save-dev
```
パッケージをインストールしたらprismaの初期化を行います。
```
$ npx prisma init
```
## 接続先DBの設定
prismaを初期化すると`.env`ファイルが作成されるので、DATABASE_URLに先ほど立ち上げたDBの情報を記載します。
```.env
DATABASE_URL="postgresql://user:password@localhost:5432/nextjsdb?schema=public"
```

## schema.prismaの作成
schema.prismaを作成します。Next-authとprismaを使用する場合は、公式で作成する項目が決まっています。
[@auth/prisma-adapter](https://authjs.dev/reference/adapter/prisma)

```schema.prisma
// This is your Prisma schema file,
// learn more about it in the docs: https://pris.ly/d/prisma-schema

generator client {
  provider = "prisma-client-js"
  previewFeatures = ["referentialActions"]
}

datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
}

model Account {
  id                 String  @id @default(cuid())
  userId             String
  type               String
  provider           String
  providerAccountId  String
  refresh_token      String?  @db.Text
  access_token       String?  @db.Text
  expires_at         Int?
  token_type         String?
  scope              String?
  id_token           String?  @db.Text
  session_state      String?

  user User @relation(fields: [userId], references: [id], onDelete: Cascade)

  @@unique([provider, providerAccountId])
}

model Session {
  id           String   @id @default(cuid())
  sessionToken String   @unique
  userId       String
  expires      DateTime
  user         User     @relation(fields: [userId], references: [id], onDelete: Cascade)
}


model User {
  id            String    @id @default(cuid())
  name          String?
  email         String?   @unique
  emailVerified DateTime?
  image         String?
  password      String
  accounts      Account[]
  sessions      Session[]
  createdAt     DateTime  @default(now())


  @@map("users")
}

model VerificationToken {
  identifier String
  token      String   @unique
  expires    DateTime

  @@unique([identifier, token])
}
```
作成したら、マイグレーションを行います。
```
$ npx prisma migrate dev --name init
```

## seedデータの作成
seed.tsを作成します。
```seed.ts
import { Prisma, PrismaClient } from "@prisma/client";
import { hashSync } from "bcryptjs";

const prisma = new PrismaClient();

async function main() {
  await prisma.user.deleteMany();

  const users: Prisma.UserCreateInput[] = [
    {
      email: "test01@test.com",
      name: "testuser01",
      password: hashSync("test01", 8),
    },
    {
      email: "test02@test.com",
      name: "testuser02",
      password: hashSync("test02", 8),
    },
    {
      email: "test03@test.com",
      name: "testuser03",
      password: hashSync("test03", 8),
    },
  ];

  for (const user of users) {
    await prisma.user.create({
      data: user
    });
  }
}

main()
  .then(async () => {
    await prisma.$disconnect()
  })
  .catch(async (e) => {
    console.error(e);
    await prisma.$disconnect();
    process.exit(1);
  })
```
seed.tsを作成たら、データベースにseedを保存します。そして、prisma studioを立ち上げ、`localhost:5555`にブラウザから接続すると、seedデータが格納されていることがわかります。
```
$ npx prisma db seed
$ npx prisma studio
```
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2822425/b0008b7c-fbfa-0f0e-772c-c71c6f49376b.png)
## global/db.tsの作成
開発中にnext devコマンドを実行すると、Node.jsのキャッシュがクリアされます。これにより、ホットリロードが行われるたびに新たなPrismaClientインスタンスが初期化され、データベースへの接続が作成されます。PrismaClientインスタンスごとに独自の接続プールが保持されるため、これによりすぐにデータベースの接続が枯渇する可能性があります。
https://www.prisma.io/docs/guides/other/troubleshooting-orm/help-articles/nextjs-prisma-client-dev-practices
そのため、global/db.tsを作成します。
```global/db.ts
import { PrismaClient } from "@prisma/client";
import "server-only";

const globalForPrisma = global as unknown as { prisma: PrismaClient}

export const prisma = 
  globalForPrisma.prisma ||
  new PrismaClient({
    log: ["query"],
  });

if (process.env.NODE_ENV != "production") globalForPrisma.prisma = prisma
```
これでprismaの設定は完了です。

# APIの作成
まず、zodを使ってログイン、ユーザ作成に必要な情報と型を定義します。
```type.ts
import { z } from "zod";

export const zUser = z.object({
  email: z.string(),
  name: z.string(),
  password: z.string(),
});

export const zLogin = z.object({
  email: z.string(),
  password: z.string(),
});

export type User = z.infer<typeof zUser>;
```
ユーザ作成のためのAPIルートを作成します。
```register/route.ts
import { NextRequest, NextResponse } from "next/server";
import { zUser } from "../type";
import bcryptjs from "bcryptjs";
import { prisma } from "@/global/db";

export async function POST(req: NextRequest) {
  const data = await req.json();
  const parsedData = zUser.parse(data);
  const hashedPassword = bcryptjs.hashSync(parsedData.password, 8)
  try {
    const user = await prisma.user.create({
      data: {
        email: parsedData.email,
        name: parsedData.name,
        password: hashedPassword,
      }
    })
    return new NextResponse(`{id: ${user.id}, name: ${user.name}}`, { status: 201})
  } catch (error) {
    return new NextResponse(`error: `, {status: 500});
  }
}
```
ここでは、リクエストで受け取ったパスワードをbcryptjsの`hashSync`を使って暗号化しています。
そして、受け取ったデータをもとにDBにユーザを追加しています。

次に、ログインのためのAPIルートを作成します。
```login/route.ts
import { NextRequest, NextResponse } from "next/server";
import { zLogin } from "../type";
import { prisma } from "@/global/db";
import { compare } from "bcryptjs";
import { sign } from "jsonwebtoken";

export async function POST(req: NextRequest) {
  const data = await req.json();
  const parsedData = zLogin.parse(data);
  const user = await prisma.user.findFirst({
    where: {
      email: parsedData.email,
    }
  });
  if ( user === null) {
    return new NextResponse("user not found", { status: 401 })
  } else {
    const comparePassword = async () => {
      const isMatch = await compare(parsedData.password, user.password)
      if(!isMatch) {
        return new NextResponse("invalid password", { status: 401 })
      }
      return NextResponse.json({id: user.id, name: user.name, email: user.email})
    }
    return await comparePassword();
  }
}
```
リクエストでemailとpasswordを受け取り、`prisma.user.findFirst`で受け取ったemailと合致するユーザを探して、`compare`で保存されているパスワードと受け取ったパスワードを比較します。合致したらステータス200のレスポンスを返します。

# next-authの設定
必要なパッケージをインストールします。
```
$ npm i next-auth
```
## APIルートの作成
next-authのAPIルートは、`api/auth/[...nextauth]/route.tsの中に作成する必要があります。
```[...nextauth]/route.ts
import { options } from "@/app/options";
import NextAuth from "next-auth/next";

const handler = NextAuth(options);

export { handler as GET, handler as POST}
```

```options.ts
import { prisma } from "@/global/db";
import { PrismaAdapter } from "@next-auth/prisma-adapter";
import type { NextAuthOptions } from "next-auth";
import CredentialsProvider from "next-auth/providers/credentials";



export const options: NextAuthOptions = {
  adapter: PrismaAdapter(prisma),
  debug: true,
  session: {strategy: "jwt"},
  providers: [
    CredentialsProvider({
      name: "Sign in",
      credentials: {
        email: {
          label: "email",
          type: "Email",
          placeholder: "example",
        },
        password: {label: "password", type: "pasword"}
      },
      async authorize(credentials) {
        const res = await fetch("http://localhost:3000/api/login", {
          method: "POST",
          body: JSON.stringify({
            email: credentials?.email,
            password: credentials?.password
          }),
          headers: {"Content-Type": "application/json"}
        })
        const user = await res.json()
        if (res.ok && user) {
          return {id: user.id, name: user.email, email: user.email, role: "admin"}
        }
        return null
      }
    })
  ],

  callbacks: {
    async jwt({token, user, account, profile}){
      if (user) {
        token.user = user;
        const u = user as any
        token.role = u.role
      }
      if (account) {
        token.accessToken = account.access_token
      }
      return token
    },
    async session({ session, token, user}) {
      token.accessToken
      session.user = token.user as any
      return {
        ...session,
        user: {
          ...session.user,
          role: token.role
        }
      }
    }
  }
}
```
## middlewareの設定
プロジェクトのルートディレクトリ上にmiddleware.tsを作成します。
```middleware.ts
import { withAuth } from "next-auth/middleware"

export default withAuth(
  {
    callbacks: {
      authorized: ({token}) => {
        return token?.role === "admin"
      }
    },
    pages: {
      signIn: "/signIn",
    },
  }
) 

export const config = {
  matcher: ["/((?!signUp|api|signIn).*)"],
}
```

pagesの`signIn`で、サインインページを指定しています。
また、configの`matcher`で、signUp, api, signInはリダイレクト処理の対象から除外しています。

# ログイン画面の作成
`app/signIn/SignIn.tsx`を作成します。
```SignIn.tsx
"use client"
import React, { FormEvent, useState } from "react";
import { useRouter } from "next/navigation";
import { Avatar, Box, Button, Container, CssBaseline, Grid, TextField, Typography } from "@mui/material";

import LockOutlinedIcon from '@mui/icons-material/LockOutlined';
import { signIn } from "next-auth/react";
import Link from "next/link";

function SignIn() {
  const router = useRouter();

  const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
    event.preventDefault();
    const data = new FormData(event.currentTarget);

    const mail = data.get("userId");
    const password = data.get("password");

    await signIn("credentials", {
      redirect: false,
      email: mail,
      password,
    }).then(res => {
      if (res?.error) {
        console.log(res.error)
      }
      router.push("/")
    }).catch(err =>{
      console.log(err)
    })

  }

  return (
    // 以下略
```
省略している部分はリポジトリを参照してください。
https://github.com/shalXXXX/prisma_jwt/blob/main/app/signIn/SignIn.tsx

ログインボタンが押された際に`handleSubmit`が実行されます。
handleSubmitの中では、next-authの`signIn`が呼び出されます。
そして、認証が通るとクッキーにトークン等が保存されて別のページにアクセスできるようになります。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2822425/d7899671-f674-28b9-2f02-90203ad00de5.png)

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2822425/5d02cb2d-fcc5-065a-f9c6-a1ee47bf977d.png)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2822425/0dcfadb1-57c8-2e42-902b-010e850d42e5.png)

# まとめ

この記事では、Next.js 13とNext-Authを用いてApp routerを使用したJWT認証とリダイレクト処理の実装方法を見てきました。これにより、ユーザーがセキュアに認証を行い、その後適切な場所にリダイレクトされるアプリケーションを構築することができます。

# 参考
- [新しいNext.jsの入門 ─ App DirectoryによるWeb開発をハンズオンで理解しよう](https://eh-career.com/engineerhub/entry/2023/04/18/093000)
- [Next.js 13 App Router での Auth.js の使い方](https://zenn.dev/tfutada/articles/5557b780050574)